Auto extend with Sanic Extensions (#2308)
This commit is contained in:
parent
b91ffed010
commit
dc3ccba527
110
sanic/app.py
110
sanic/app.py
|
@ -28,6 +28,7 @@ from ssl import SSLContext
|
|||
from traceback import format_exc
|
||||
from types import SimpleNamespace
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AnyStr,
|
||||
Awaitable,
|
||||
|
@ -41,6 +42,7 @@ from typing import (
|
|||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
from urllib.parse import urlencode, urlunparse
|
||||
|
@ -53,6 +55,7 @@ from sanic_routing.exceptions import ( # type: ignore
|
|||
from sanic_routing.route import Route # type: ignore
|
||||
|
||||
from sanic import reloader_helpers
|
||||
from sanic.application.ext import setup_ext
|
||||
from sanic.application.logo import get_logo
|
||||
from sanic.application.motd import MOTD
|
||||
from sanic.application.state import ApplicationState, Mode
|
||||
|
@ -103,11 +106,21 @@ from sanic.tls import process_to_context
|
|||
from sanic.touchup import TouchUp, TouchUpMeta
|
||||
|
||||
|
||||
if TYPE_CHECKING: # no cov
|
||||
try:
|
||||
from sanic_ext import Extend # type: ignore
|
||||
from sanic_ext.extensions.base import Extension # type: ignore
|
||||
except ImportError:
|
||||
Extend = TypeVar("Extend") # type: ignore
|
||||
|
||||
|
||||
if OS_IS_WINDOWS:
|
||||
enable_windows_color_support()
|
||||
|
||||
filterwarnings("once", category=DeprecationWarning)
|
||||
|
||||
SANIC_PACKAGES = ("sanic-routing", "sanic-testing", "sanic-ext")
|
||||
|
||||
|
||||
class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
||||
"""
|
||||
|
@ -125,6 +138,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
"_asgi_client",
|
||||
"_blueprint_order",
|
||||
"_delayed_tasks",
|
||||
"_ext",
|
||||
"_future_exceptions",
|
||||
"_future_listeners",
|
||||
"_future_middleware",
|
||||
|
@ -1421,26 +1435,15 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
"#proxy-configuration"
|
||||
)
|
||||
|
||||
ssl = process_to_context(ssl)
|
||||
|
||||
self.debug = debug
|
||||
self.state.host = host
|
||||
self.state.port = port
|
||||
self.state.workers = workers
|
||||
|
||||
# Serve
|
||||
serve_location = ""
|
||||
proto = "http"
|
||||
if ssl is not None:
|
||||
proto = "https"
|
||||
if unix:
|
||||
serve_location = f"{unix} {proto}://..."
|
||||
elif sock:
|
||||
serve_location = f"{sock.getsockname()} {proto}://..."
|
||||
elif host and port:
|
||||
# colon(:) is legal for a host only in an ipv6 address
|
||||
display_host = f"[{host}]" if ":" in host else host
|
||||
serve_location = f"{proto}://{display_host}:{port}"
|
||||
|
||||
ssl = process_to_context(ssl)
|
||||
self.state.ssl = ssl
|
||||
self.state.unix = unix
|
||||
self.state.sock = sock
|
||||
|
||||
server_settings = {
|
||||
"protocol": protocol,
|
||||
|
@ -1456,7 +1459,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
"backlog": backlog,
|
||||
}
|
||||
|
||||
self.motd(serve_location)
|
||||
self.motd(self.serve_location)
|
||||
|
||||
if sys.stdout.isatty() and not self.state.is_debug:
|
||||
error_logger.warning(
|
||||
|
@ -1482,6 +1485,27 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
|
||||
return server_settings
|
||||
|
||||
@property
|
||||
def serve_location(self) -> str:
|
||||
serve_location = ""
|
||||
proto = "http"
|
||||
if self.state.ssl is not None:
|
||||
proto = "https"
|
||||
if self.state.unix:
|
||||
serve_location = f"{self.state.unix} {proto}://..."
|
||||
elif self.state.sock:
|
||||
serve_location = f"{self.state.sock.getsockname()} {proto}://..."
|
||||
elif self.state.host and self.state.port:
|
||||
# colon(:) is legal for a host only in an ipv6 address
|
||||
display_host = (
|
||||
f"[{self.state.host}]"
|
||||
if ":" in self.state.host
|
||||
else self.state.host
|
||||
)
|
||||
serve_location = f"{proto}://{display_host}:{self.state.port}"
|
||||
|
||||
return serve_location
|
||||
|
||||
def _build_endpoint_name(self, *parts):
|
||||
parts = [self.name, *parts]
|
||||
return ".".join(parts)
|
||||
|
@ -1790,11 +1814,8 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
display["auto-reload"] = reload_display
|
||||
|
||||
packages = []
|
||||
for package_name, module_name in {
|
||||
"sanic-routing": "sanic_routing",
|
||||
"sanic-testing": "sanic_testing",
|
||||
"sanic-ext": "sanic_ext",
|
||||
}.items():
|
||||
for package_name in SANIC_PACKAGES:
|
||||
module_name = package_name.replace("-", "_")
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
packages.append(f"{package_name}=={module.__version__}")
|
||||
|
@ -1814,6 +1835,41 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
)
|
||||
MOTD.output(logo, serve_location, display, extra)
|
||||
|
||||
@property
|
||||
def ext(self) -> Extend:
|
||||
if not hasattr(self, "_ext"):
|
||||
setup_ext(self, fail=True)
|
||||
|
||||
if not hasattr(self, "_ext"):
|
||||
raise RuntimeError(
|
||||
"Sanic Extensions is not installed. You can add it to your "
|
||||
"environment using:\n$ pip install sanic[ext]\nor\n$ pip "
|
||||
"install sanic-ext"
|
||||
)
|
||||
return self._ext # type: ignore
|
||||
|
||||
def extend(
|
||||
self,
|
||||
*,
|
||||
extensions: Optional[List[Type[Extension]]] = None,
|
||||
built_in_extensions: bool = True,
|
||||
config: Optional[Union[Config, Dict[str, Any]]] = None,
|
||||
**kwargs,
|
||||
) -> Extend:
|
||||
if hasattr(self, "_ext"):
|
||||
raise RuntimeError(
|
||||
"Cannot extend Sanic after Sanic Extensions has been setup."
|
||||
)
|
||||
setup_ext(
|
||||
self,
|
||||
extensions=extensions,
|
||||
built_in_extensions=built_in_extensions,
|
||||
config=config,
|
||||
fail=True,
|
||||
**kwargs,
|
||||
)
|
||||
return self.ext
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# Class methods
|
||||
# -------------------------------------------------------------------- #
|
||||
|
@ -1875,6 +1931,14 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
|
||||
async def _startup(self):
|
||||
self._future_registry.clear()
|
||||
|
||||
# Startup Sanic Extensions
|
||||
if not hasattr(self, "_ext"):
|
||||
setup_ext(self)
|
||||
if hasattr(self, "_ext"):
|
||||
self.ext._display()
|
||||
|
||||
# Setup routers
|
||||
self.signalize()
|
||||
self.finalize()
|
||||
|
||||
|
@ -1890,8 +1954,10 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
)
|
||||
self.__class__._uvloop_setting = self.config.USE_UVLOOP
|
||||
|
||||
# Startup time optimizations
|
||||
ErrorHandler.finalize(self.error_handler, config=self.config)
|
||||
TouchUp.run(self)
|
||||
|
||||
self.state.is_started = True
|
||||
|
||||
async def _server_event(
|
||||
|
|
39
sanic/application/ext.py
Normal file
39
sanic/application/ext.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic import Sanic
|
||||
|
||||
try:
|
||||
from sanic_ext import Extend # type: ignore
|
||||
except ImportError:
|
||||
...
|
||||
|
||||
|
||||
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
|
||||
if not app.config.AUTO_EXTEND:
|
||||
return
|
||||
|
||||
sanic_ext = None
|
||||
with suppress(ModuleNotFoundError):
|
||||
sanic_ext = import_module("sanic_ext")
|
||||
|
||||
if not sanic_ext:
|
||||
if fail:
|
||||
raise RuntimeError(
|
||||
"Sanic Extensions is not installed. You can add it to your "
|
||||
"environment using:\n$ pip install sanic[ext]\nor\n$ pip "
|
||||
"install sanic-ext"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
if not getattr(app, "_ext", None):
|
||||
Ext: Extend = getattr(sanic_ext, "Extend")
|
||||
app._ext = Ext(app, **kwargs)
|
||||
|
||||
return app.ext
|
|
@ -41,9 +41,6 @@ class MOTD(ABC):
|
|||
|
||||
|
||||
class MOTDBasic(MOTD):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def display(self):
|
||||
if self.logo:
|
||||
logger.debug(self.logo)
|
||||
|
|
|
@ -5,7 +5,9 @@ import logging
|
|||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Set, Union
|
||||
from socket import socket
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING, Any, Optional, Set, Union
|
||||
|
||||
from sanic.log import logger
|
||||
|
||||
|
@ -37,8 +39,11 @@ class ApplicationState:
|
|||
coffee: bool = field(default=False)
|
||||
fast: bool = field(default=False)
|
||||
host: str = field(default="")
|
||||
mode: Mode = field(default=Mode.PRODUCTION)
|
||||
port: int = field(default=0)
|
||||
ssl: Optional[SSLContext] = field(default=None)
|
||||
sock: Optional[socket] = field(default=None)
|
||||
unix: Optional[str] = field(default=None)
|
||||
mode: Mode = field(default=Mode.PRODUCTION)
|
||||
reload_dirs: Set[Path] = field(default_factory=set)
|
||||
server: Server = field(default=Server.SANIC)
|
||||
is_running: bool = field(default=False)
|
||||
|
|
|
@ -5,7 +5,7 @@ from functools import partial
|
|||
from typing import TYPE_CHECKING, List, Optional, Union
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic.blueprints import Blueprint
|
||||
|
||||
|
||||
|
|
|
@ -36,8 +36,8 @@ from sanic.models.handler_types import (
|
|||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic # noqa
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
def lazy(func, as_decorator=True):
|
||||
|
|
|
@ -18,6 +18,7 @@ SANIC_PREFIX = "SANIC_"
|
|||
DEFAULT_CONFIG = {
|
||||
"_FALLBACK_ERROR_FORMAT": _default,
|
||||
"ACCESS_LOG": True,
|
||||
"AUTO_EXTEND": True,
|
||||
"AUTO_RELOAD": False,
|
||||
"EVENT_AUTOREGISTER": False,
|
||||
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
|
||||
|
@ -59,6 +60,7 @@ class DescriptorMeta(type):
|
|||
|
||||
class Config(dict, metaclass=DescriptorMeta):
|
||||
ACCESS_LOG: bool
|
||||
AUTO_EXTEND: bool
|
||||
AUTO_RELOAD: bool
|
||||
EVENT_AUTOREGISTER: bool
|
||||
FORWARDED_FOR_HEADER: str
|
||||
|
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic.request import Request
|
||||
from sanic.response import BaseHTTPResponse
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ from typing import (
|
|||
from sanic_routing.route import Route # type: ignore
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic.server import ConnInfo
|
||||
from sanic.app import Sanic
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ from functools import partial
|
|||
from signal import SIG_IGN, SIGINT, SIGTERM, Signals
|
||||
from signal import signal as signal_func
|
||||
|
||||
from sanic.application.ext import setup_ext
|
||||
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.server_types import Signal
|
||||
|
@ -116,6 +117,7 @@ def serve(
|
|||
**asyncio_server_kwargs,
|
||||
)
|
||||
|
||||
setup_ext(app)
|
||||
if run_async:
|
||||
return AsyncioServer(
|
||||
app=app,
|
||||
|
|
|
@ -13,7 +13,7 @@ from typing import (
|
|||
from sanic.models.handler_types import RouteHandler
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic import Sanic
|
||||
from sanic.blueprints import Blueprint
|
||||
|
||||
|
@ -81,6 +81,8 @@ class HTTPMethodView:
|
|||
|
||||
def dispatch_request(self, request, *args, **kwargs):
|
||||
handler = getattr(self, request.method.lower(), None)
|
||||
if not handler and request.method == "HEAD":
|
||||
handler = self.get
|
||||
return handler(request, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -15,10 +15,10 @@ from sanic.server.protocols.websocket_protocol import WebSocketProtocol
|
|||
|
||||
try:
|
||||
import ssl # type: ignore
|
||||
except ImportError:
|
||||
except ImportError: # no cov
|
||||
ssl = None # type: ignore
|
||||
|
||||
if UVLOOP_INSTALLED:
|
||||
if UVLOOP_INSTALLED: # no cov
|
||||
try_use_uvloop()
|
||||
|
||||
|
||||
|
|
1
setup.py
1
setup.py
|
@ -147,6 +147,7 @@ extras_require = {
|
|||
"dev": dev_require,
|
||||
"docs": docs_require,
|
||||
"all": all_require,
|
||||
"ext": ["sanic-ext"],
|
||||
}
|
||||
|
||||
setup_kwargs["install_requires"] = requirements
|
||||
|
|
|
@ -6,8 +6,10 @@ import string
|
|||
import sys
|
||||
import uuid
|
||||
|
||||
from contextlib import suppress
|
||||
from logging import LogRecord
|
||||
from typing import Callable, List, Tuple
|
||||
from typing import List, Tuple
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
@ -184,3 +186,21 @@ def message_in_records():
|
|||
return error_captured
|
||||
|
||||
return msg_in_log
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_instance():
|
||||
ext_instance = MagicMock()
|
||||
ext_instance.injection = MagicMock()
|
||||
return ext_instance
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True) # type: ignore
|
||||
def sanic_ext(ext_instance): # noqa
|
||||
sanic_ext = MagicMock(__version__="1.2.3")
|
||||
sanic_ext.Extend = MagicMock()
|
||||
sanic_ext.Extend.return_value = ext_instance
|
||||
sys.modules["sanic_ext"] = sanic_ext
|
||||
yield sanic_ext
|
||||
with suppress(KeyError):
|
||||
del sys.modules["sanic_ext"]
|
||||
|
|
|
@ -32,6 +32,12 @@ def starting_line(lines):
|
|||
return 0
|
||||
|
||||
|
||||
def read_app_info(lines):
|
||||
for line in lines:
|
||||
if line.startswith(b"{") and line.endswith(b"}"):
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"appname",
|
||||
(
|
||||
|
@ -199,9 +205,7 @@ def test_debug(cmd):
|
|||
command = ["sanic", "fake.server.app", cmd]
|
||||
out, err, exitcode = capture(command)
|
||||
lines = out.split(b"\n")
|
||||
|
||||
app_info = lines[starting_line(lines) + 9]
|
||||
info = json.loads(app_info)
|
||||
info = read_app_info(lines)
|
||||
|
||||
assert info["debug"] is True
|
||||
assert info["auto_reload"] is True
|
||||
|
@ -212,9 +216,7 @@ def test_auto_reload(cmd):
|
|||
command = ["sanic", "fake.server.app", cmd]
|
||||
out, err, exitcode = capture(command)
|
||||
lines = out.split(b"\n")
|
||||
|
||||
app_info = lines[starting_line(lines) + 9]
|
||||
info = json.loads(app_info)
|
||||
info = read_app_info(lines)
|
||||
|
||||
assert info["debug"] is False
|
||||
assert info["auto_reload"] is True
|
||||
|
@ -227,9 +229,7 @@ def test_access_logs(cmd, expected):
|
|||
command = ["sanic", "fake.server.app", cmd]
|
||||
out, err, exitcode = capture(command)
|
||||
lines = out.split(b"\n")
|
||||
|
||||
app_info = lines[starting_line(lines) + 8]
|
||||
info = json.loads(app_info)
|
||||
info = read_app_info(lines)
|
||||
|
||||
assert info["access_log"] is expected
|
||||
|
||||
|
@ -254,8 +254,6 @@ def test_noisy_exceptions(cmd, expected):
|
|||
command = ["sanic", "fake.server.app", cmd]
|
||||
out, err, exitcode = capture(command)
|
||||
lines = out.split(b"\n")
|
||||
|
||||
app_info = lines[starting_line(lines) + 8]
|
||||
info = json.loads(app_info)
|
||||
info = read_app_info(lines)
|
||||
|
||||
assert info["noisy_exceptions"] is expected
|
||||
|
|
84
tests/test_ext_integration.py
Normal file
84
tests/test_ext_integration.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
import sys
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
try:
|
||||
import sanic_ext
|
||||
|
||||
SANIC_EXT_IN_ENV = True
|
||||
except ImportError:
|
||||
SANIC_EXT_IN_ENV = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stoppable_app(app):
|
||||
@app.before_server_start
|
||||
async def stop(*_):
|
||||
app.stop()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_ext_is_loaded(stoppable_app: Sanic, sanic_ext):
|
||||
stoppable_app.run()
|
||||
sanic_ext.Extend.assert_called_once_with(stoppable_app)
|
||||
|
||||
|
||||
def test_ext_is_not_loaded(stoppable_app: Sanic, sanic_ext):
|
||||
stoppable_app.config.AUTO_EXTEND = False
|
||||
stoppable_app.run()
|
||||
sanic_ext.Extend.assert_not_called()
|
||||
|
||||
|
||||
def test_extend_with_args(stoppable_app: Sanic, sanic_ext):
|
||||
stoppable_app.extend(built_in_extensions=False)
|
||||
stoppable_app.run()
|
||||
sanic_ext.Extend.assert_called_once_with(
|
||||
stoppable_app, built_in_extensions=False, config=None, extensions=None
|
||||
)
|
||||
|
||||
|
||||
def test_access_object_sets_up_extension(app: Sanic, sanic_ext):
|
||||
app.ext
|
||||
sanic_ext.Extend.assert_called_once_with(app)
|
||||
|
||||
|
||||
def test_extend_cannot_be_called_multiple_times(app: Sanic, sanic_ext):
|
||||
app.extend()
|
||||
|
||||
message = "Cannot extend Sanic after Sanic Extensions has been setup."
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
app.extend()
|
||||
sanic_ext.Extend.assert_called_once_with(
|
||||
app, extensions=None, built_in_extensions=True, config=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
SANIC_EXT_IN_ENV,
|
||||
reason="Running tests with sanic_ext already in the environment",
|
||||
)
|
||||
def test_fail_if_not_loaded(app: Sanic):
|
||||
del sys.modules["sanic_ext"]
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Sanic Extensions is not installed.*"
|
||||
):
|
||||
app.extend(built_in_extensions=False)
|
||||
|
||||
|
||||
def test_can_access_app_ext_while_running(app: Sanic, sanic_ext, ext_instance):
|
||||
class IceCream:
|
||||
flavor: str
|
||||
|
||||
@app.before_server_start
|
||||
async def injections(*_):
|
||||
app.ext.injection(IceCream)
|
||||
app.stop()
|
||||
|
||||
app.run()
|
||||
ext_instance.injection.assert_called_with(IceCream)
|
Loading…
Reference in New Issue
Block a user