diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 39e06a79..00000000 --- a/.deepsource.toml +++ /dev/null @@ -1,12 +0,0 @@ -version = 1 - -test_patterns = ["tests/**"] - -exclude_patterns = ["docker/**"] - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" \ No newline at end of file diff --git a/sanic/app.py b/sanic/app.py index a411e0cf..338e22ce 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -3,7 +3,13 @@ import logging.config import os import re -from asyncio import CancelledError, Protocol, ensure_future, get_event_loop +from asyncio import ( + CancelledError, + Protocol, + ensure_future, + get_event_loop, + wait_for, +) from asyncio.futures import Future from collections import defaultdict, deque from functools import partial @@ -13,7 +19,9 @@ from ssl import Purpose, SSLContext, create_default_context from traceback import format_exc from typing import ( Any, + Awaitable, Callable, + Coroutine, Deque, Dict, Iterable, @@ -26,6 +34,7 @@ from typing import ( from urllib.parse import urlencode, urlunparse from sanic_routing.exceptions import FinalizationError # type: ignore +from sanic_routing.exceptions import NotFound # type: ignore from sanic_routing.route import Route # type: ignore from sanic import reloader_helpers @@ -48,20 +57,17 @@ from sanic.models.futures import ( FutureListener, FutureMiddleware, FutureRoute, + FutureSignal, FutureStatic, ) from sanic.models.handler_types import ListenerType, MiddlewareType from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router -from sanic.server import ( - AsyncioServer, - HttpProtocol, - Signal, - serve, - serve_multiple, - serve_single, -) +from sanic.server import AsyncioServer, HttpProtocol +from sanic.server import Signal as ServerSignal +from sanic.server import serve, serve_multiple, serve_single +from sanic.signals import Signal, SignalRouter from sanic.websocket import ConnectionClosed, WebSocketProtocol @@ -76,10 +82,11 @@ class Sanic(BaseSanic): def __init__( self, name: str = None, - router: Router = None, - error_handler: ErrorHandler = None, + router: Optional[Router] = None, + signal_router: Optional[SignalRouter] = None, + error_handler: Optional[ErrorHandler] = None, load_env: bool = True, - request_class: Type[Request] = None, + request_class: Optional[Type[Request]] = None, strict_slashes: bool = False, log_config: Optional[Dict[str, Any]] = None, configure_logging: bool = True, @@ -100,6 +107,7 @@ class Sanic(BaseSanic): self.name = name 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) @@ -162,7 +170,7 @@ class Sanic(BaseSanic): also return a future, and the actual ensure_future call is delayed until before server start. - `See user guide + `See user guide re: background tasks `__ :param task: future, couroutine or awaitable @@ -309,6 +317,28 @@ class Sanic(BaseSanic): middleware.middleware, middleware.attach_to ) + def _apply_signal(self, signal: FutureSignal) -> Signal: + return self.signal_router.add(*signal) + + def dispatch( + self, + event: str, + *, + condition: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, + ) -> Coroutine[Any, Any, Awaitable[Any]]: + return self.signal_router.dispatch( + event, + context=context, + condition=condition, + ) + + def event(self, event: str, timeout: Optional[Union[int, float]] = None): + signal = self.signal_router.name_index.get(event) + if not signal: + raise NotFound("Could not find signal %s" % event) + return wait_for(signal.ctx.event.wait(), timeout=timeout) + def enable_websocket(self, enable=True): """Enable or disable the support for websocket. @@ -382,7 +412,7 @@ class Sanic(BaseSanic): app.config.SERVER_NAME = "myserver:7777" - `See user guide + `See user guide re: routing `__ :param view_name: string referencing the view name @@ -1031,11 +1061,9 @@ class Sanic(BaseSanic): ): """Helper function used by `run` and `create_server`.""" - try: - self.router.finalize() - except FinalizationError as e: - if not Sanic.test_mode: - raise e + self.listeners["before_server_start"] = [ + self.finalize + ] + self.listeners["before_server_start"] if isinstance(ssl, dict): # try common aliaseses @@ -1064,7 +1092,7 @@ class Sanic(BaseSanic): "unix": unix, "ssl": ssl, "app": self, - "signal": Signal(), + "signal": ServerSignal(), "loop": loop, "register_sys_signals": register_sys_signals, "backlog": backlog, @@ -1159,7 +1187,7 @@ class Sanic(BaseSanic): """ Update app.config. Full implementation can be found in the user guide. - `See user guide + `See user guide re: configuration `__ """ @@ -1196,7 +1224,7 @@ class Sanic(BaseSanic): 'Multiple Sanic apps found, use Sanic.get_app("app_name")' ) elif len(cls._app_registry) == 0: - raise SanicException(f"No Sanic apps have been registered.") + raise SanicException("No Sanic apps have been registered.") else: return list(cls._app_registry.values())[0] try: @@ -1205,3 +1233,17 @@ class Sanic(BaseSanic): if force_create: return cls(name) raise SanicException(f'Sanic app name "{name}" not found.') + + # -------------------------------------------------------------------- # + # Static methods + # -------------------------------------------------------------------- # + + @staticmethod + async def finalize(app, _): + try: + app.router.finalize() + if app.signal_router.routes: + app.signal_router.finalize() # noqa + except FinalizationError as e: + if not Sanic.test_mode: + raise e # noqa diff --git a/sanic/asgi.py b/sanic/asgi.py index dd9f8c11..205b7187 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -43,6 +43,8 @@ class Lifespan: startup event. """ self.asgi_app.sanic_app.router.finalize() + if self.asgi_app.sanic_app.signal_router.routes: + self.asgi_app.sanic_app.signal_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 index ac7f06c8..d231ab54 100644 --- a/sanic/base.py +++ b/sanic/base.py @@ -2,6 +2,7 @@ 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.mixins.signals import SignalMixin class Base(type): @@ -31,6 +32,7 @@ class BaseSanic( MiddlewareMixin, ListenerMixin, ExceptionMixin, + SignalMixin, metaclass=Base, ): def __str__(self) -> str: diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 33a03993..27bdfd6f 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,10 +1,16 @@ -from collections import defaultdict -from typing import Dict, Iterable, List, Optional +from __future__ import annotations +import asyncio + +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Union + +from sanic_routing.exceptions import NotFound # type: ignore from sanic_routing.route import Route # type: ignore from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup +from sanic.exceptions import SanicException from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.handler_types import ( ListenerType, @@ -13,6 +19,10 @@ from sanic.models.handler_types import ( ) +if TYPE_CHECKING: + from sanic import Sanic # noqa + + class Blueprint(BaseSanic): """ In *Sanic* terminology, a **Blueprint** is a logical collection of @@ -21,7 +31,7 @@ class Blueprint(BaseSanic): It is the main tool for grouping functionality and similar endpoints. - `See user guide + `See user guide re: blueprints `__ :param name: unique name of the blueprint @@ -40,6 +50,7 @@ class Blueprint(BaseSanic): 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 @@ -70,6 +81,14 @@ class Blueprint(BaseSanic): ) return f"Blueprint({args})" + @property + def apps(self): + if not self._apps: + raise SanicException( + f"{self} has not yet been registered to an app" + ) + return self._apps + def route(self, *args, **kwargs): kwargs["apply"] = False return super().route(*args, **kwargs) @@ -90,6 +109,10 @@ class Blueprint(BaseSanic): kwargs["apply"] = False return super().exception(*args, **kwargs) + def signal(self, event: str, *args, **kwargs): + kwargs["apply"] = False + return super().signal(event, *args, **kwargs) + @staticmethod def group(*blueprints, url_prefix="", version=None, strict_slashes=None): """ @@ -132,6 +155,7 @@ class Blueprint(BaseSanic): *url_prefix* - URL Prefix to override the blueprint prefix """ + self._apps.add(app) url_prefix = options.get("url_prefix", self.url_prefix) routes = [] @@ -200,6 +224,10 @@ class Blueprint(BaseSanic): for listener in self._future_listeners: listeners[listener.event].append(app._apply_listener(listener)) + for signal in self._future_signals: + signal.condition.update({"blueprint": self.name}) + app._apply_signal(signal) + self.routes = [route for route in routes if isinstance(route, Route)] # Deprecate these in 21.6 @@ -209,3 +237,25 @@ class Blueprint(BaseSanic): self.middlewares = middleware self.exceptions = exception_handlers self.listeners = dict(listeners) + + async def dispatch(self, *args, **kwargs): + condition = kwargs.pop("condition", {}) + condition.update({"blueprint": self.name}) + kwargs["condition"] = condition + await asyncio.gather( + *[app.dispatch(*args, **kwargs) for app in self.apps] + ) + + def event(self, event: str, timeout: Optional[Union[int, float]] = None): + events = set() + for app in self.apps: + signal = app.signal_router.name_index.get(event) + if not signal: + raise NotFound("Could not find signal %s" % event) + events.add(signal.ctx.event) + + return asyncio.wait( + [event.wait() for event in events], + return_when=asyncio.FIRST_COMPLETED, + timeout=timeout, + ) diff --git a/sanic/config.py b/sanic/config.py index 042f03d6..80402c24 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -134,7 +134,7 @@ class Config(dict): config.update_config(C) - `See user guide + `See user guide re: config `__ """ diff --git a/sanic/exceptions.py b/sanic/exceptions.py index e65bd4f8..f21a296c 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -227,6 +227,10 @@ class LoadFileException(SanicException): pass +class InvalidSignal(SanicException): + pass + + def abort(status_code: int, message: Optional[Union[str, bytes]] = None): """ Raise an exception based on SanicException. Returns the HTTP response diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index 53d4f144..bcb56988 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -43,8 +43,8 @@ class ListenerMixin: async def before_server_start(app, loop): ... - `See user guide - `_ + `See user guide re: listeners + `__ :param event: event to listen to """ diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py index 46b69c4a..bd006062 100644 --- a/sanic/mixins/middleware.py +++ b/sanic/mixins/middleware.py @@ -19,8 +19,8 @@ class MiddlewareMixin: Can either be called as *@app.middleware* or *@app.middleware('request')* - `See user guide - `_ + `See user guide re: middleware + `__ :param: middleware_or_request: Optional parameter to use for identifying which type of middleware is being registered. diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index a8451ab2..f181e83b 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -32,7 +32,7 @@ class RouteMixin: self.name = "" self.strict_slashes: Optional[bool] = False - def _apply_route(self, route: FutureRoute) -> Route: + def _apply_route(self, route: FutureRoute) -> List[Route]: raise NotImplementedError # noqa def _apply_static(self, static: FutureStatic) -> Route: diff --git a/sanic/mixins/signals.py b/sanic/mixins/signals.py new file mode 100644 index 00000000..2d34f838 --- /dev/null +++ b/sanic/mixins/signals.py @@ -0,0 +1,62 @@ +from typing import Any, Callable, Dict, Set + +from sanic.models.futures import FutureSignal +from sanic.models.handler_types import SignalHandler +from sanic.signals import Signal + + +class HashableDict(dict): + def __hash__(self): + return hash(tuple(sorted(self.items()))) + + +class SignalMixin: + def __init__(self, *args, **kwargs) -> None: + self._future_signals: Set[FutureSignal] = set() + + def _apply_signal(self, signal: FutureSignal) -> Signal: + raise NotImplementedError # noqa + + def signal( + self, + event: str, + *, + apply: bool = True, + condition: Dict[str, Any] = None, + ) -> Callable[[SignalHandler], FutureSignal]: + """ + For creating a signal handler, used similar to a route handler: + + .. code-block:: python + + @app.signal("foo.bar.") + async def signal_handler(thing, **kwargs): + print(f"[signal_handler] {thing=}", kwargs) + + :param event: Representation of the event in ``one.two.three`` form + :type event: str + :param apply: For lazy evaluation, defaults to True + :type apply: bool, optional + :param condition: For use with the ``condition`` argument in dispatch + filtering, defaults to None + :type condition: Dict[str, Any], optional + """ + + def decorator(handler: SignalHandler): + nonlocal event + nonlocal apply + + future_signal = FutureSignal( + handler, event, HashableDict(condition or {}) + ) + self._future_signals.add(future_signal) + + if apply: + self._apply_signal(future_signal) + + return future_signal + + return decorator + + def event(self, event: str): + raise NotImplementedError diff --git a/sanic/models/futures.py b/sanic/models/futures.py index 8e8d702c..f6371b4d 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -1,10 +1,11 @@ from pathlib import PurePath -from typing import Iterable, List, NamedTuple, Optional, Union +from typing import Dict, Iterable, List, NamedTuple, Optional, Union from sanic.models.handler_types import ( ErrorMiddlewareType, ListenerType, MiddlewareType, + SignalHandler, ) @@ -50,3 +51,9 @@ class FutureStatic(NamedTuple): host: Optional[str] strict_slashes: Optional[bool] content_type: Optional[bool] + + +class FutureSignal(NamedTuple): + handler: SignalHandler + event: str + condition: Optional[Dict[str, str]] diff --git a/sanic/models/handler_types.py b/sanic/models/handler_types.py index e052e3dd..704def7a 100644 --- a/sanic/models/handler_types.py +++ b/sanic/models/handler_types.py @@ -7,7 +7,6 @@ from sanic.response import BaseHTTPResponse, HTTPResponse Sanic = TypeVar("Sanic") - MiddlewareResponse = Union[ Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]] ] @@ -23,3 +22,4 @@ ListenerType = Callable[ [Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]] ] RouteHandler = Callable[..., Coroutine[Any, Any, HTTPResponse]] +SignalHandler = Callable[..., Coroutine[Any, Any, None]] diff --git a/sanic/signals.py b/sanic/signals.py new file mode 100644 index 00000000..27f6bfa4 --- /dev/null +++ b/sanic/signals.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio + +from inspect import isawaitable +from typing import Any, Dict, List, Optional, Union + +from sanic_routing import BaseRouter, Route # type: ignore +from sanic_routing.exceptions import NotFound # type: ignore +from sanic_routing.utils import path_to_parts # type: ignore + +from sanic.exceptions import InvalidSignal +from sanic.models.handler_types import SignalHandler + + +class Signal(Route): + def get_handler(self, raw_path, method, _): + method = method or self.router.DEFAULT_METHOD + raw_path = raw_path.lstrip(self.router.delimiter) + try: + return self.handlers[raw_path][method] + except (IndexError, KeyError): + raise self.router.method_handler_exception( + f"Method '{method}' not found on {self}", + method=method, + allowed_methods=set(self.methods[raw_path]), + ) + + +class SignalRouter(BaseRouter): + def __init__(self) -> None: + super().__init__( + delimiter=".", + route_class=Signal, + stacking=True, + ) + self.ctx.loop = None + + def get( # type: ignore + self, + event: str, + condition: Optional[Dict[str, str]] = None, + ): + extra = condition or {} + try: + return self.resolve(f".{event}", extra=extra) + except NotFound: + message = "Could not find signal %s" + terms: List[Union[str, Optional[Dict[str, str]]]] = [event] + if extra: + message += " with %s" + terms.append(extra) + raise NotFound(message % tuple(terms)) + + async def _dispatch( + self, + event: str, + context: Optional[Dict[str, Any]] = None, + condition: Optional[Dict[str, str]] = None, + ) -> None: + signal, handlers, params = self.get(event, condition=condition) + + signal_event = signal.ctx.event + signal_event.set() + if context: + params.update(context) + + try: + for handler in handlers: + if condition is None or condition == handler.__requirements__: + maybe_coroutine = handler(**params) + if isawaitable(maybe_coroutine): + await maybe_coroutine + finally: + signal_event.clear() + + async def dispatch( + self, + event: str, + *, + context: Optional[Dict[str, Any]] = None, + condition: Optional[Dict[str, str]] = None, + ) -> asyncio.Task: + task = self.ctx.loop.create_task( + self._dispatch( + event, + context=context, + condition=condition, + ) + ) + await asyncio.sleep(0) + return task + + def add( # type: ignore + self, + handler: SignalHandler, + event: str, + condition: Optional[Dict[str, Any]] = None, + ) -> Signal: + parts = path_to_parts(event, self.delimiter) + + if ( + len(parts) != 3 + or parts[0].startswith("<") + or parts[1].startswith("<") + ): + raise InvalidSignal(f"Invalid signal event: {event}") + + if parts[2].startswith("<"): + name = ".".join([*parts[:-1], "*"]) + else: + name = event + + handler.__requirements__ = condition # type: ignore + + return super().add( + event, + handler, + requirements=condition, + name=name, + overwrite=True, + ) # type: ignore + + def finalize(self, do_compile: bool = True): + try: + self.ctx.loop = asyncio.get_running_loop() + except RuntimeError: + raise RuntimeError("Cannot finalize signals outside of event loop") + + for signal in self.routes.values(): + signal.ctx.event = asyncio.Event() + + return super().finalize(do_compile=do_compile) diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 51290ad4..7cce5180 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -7,7 +7,12 @@ import pytest from sanic.app import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTP_METHODS -from sanic.exceptions import InvalidUsage, NotFound, ServerError +from sanic.exceptions import ( + InvalidUsage, + NotFound, + SanicException, + ServerError, +) from sanic.request import Request from sanic.response import json, text from sanic.views import CompositionView @@ -18,6 +23,33 @@ from sanic.views import CompositionView # ------------------------------------------------------------ # +def test_bp(app): + bp = Blueprint("test_text") + + @bp.route("/") + def handler(request): + return text("Hello") + + app.blueprint(bp) + request, response = app.test_client.get("/") + + assert response.text == "Hello" + + +def test_bp_app_access(app): + bp = Blueprint("test") + + with pytest.raises( + SanicException, + match=" has not yet been registered to an app", + ): + bp.apps + + app.blueprint(bp) + + assert app in bp.apps + + @pytest.fixture(scope="module") def static_file_directory(): """The static directory to serve""" @@ -62,19 +94,6 @@ def test_versioned_routes_get(app, method): assert response.status == 200 -def test_bp(app): - bp = Blueprint("test_text") - - @bp.route("/") - def handler(request): - return text("Hello") - - app.blueprint(bp) - request, response = app.test_client.get("/") - - assert response.text == "Hello" - - def test_bp_strict_slash(app): bp = Blueprint("test_text") @@ -988,3 +1007,20 @@ def test_blueprint_group_strict_slashes(): assert app.test_client.get("/v3/slash-check/bp2/r2")[1].status == 404 assert app.test_client.get("/v3/slash-check/bp2/r2/")[1].status == 200 assert app.test_client.get("/v2/other-prefix/bp3/r1")[1].status == 200 + + +def test_blueprint_registered_multiple_apps(): + app1 = Sanic("app1") + app2 = Sanic("app2") + bp = Blueprint("bp") + + @bp.get("/") + async def handler(request): + return text(request.route.name) + + app1.blueprint(bp) + app2.blueprint(bp) + + for app in (app1, app2): + _, response = app.test_client.get("/") + assert response.text == f"{app.name}.bp.handler" diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 00000000..a2e86695 --- /dev/null +++ b/tests/test_signals.py @@ -0,0 +1,273 @@ +import asyncio + +from inspect import isawaitable + +import pytest + +from sanic_routing.exceptions import NotFound + +from sanic import Blueprint +from sanic.exceptions import InvalidSignal, SanicException + + +def test_add_signal(app): + @app.signal("foo.bar.baz") + def sync_signal(*_): + ... + + @app.signal("foo.bar.baz") + async def async_signal(*_): + ... + + assert len(app.signal_router.routes) == 1 + + +@pytest.mark.parametrize( + "signal", + ( + ".bar.bax", + "foo..baz", + "foo", + "foo.bar", + "foo.bar.baz.qux", + ), +) +def test_invalid_signal(app, signal): + with pytest.raises(InvalidSignal, match=f"Invalid signal event: {signal}"): + + @app.signal(signal) + def handler(): + ... + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_multiple_handlers(app): + counter = 0 + + @app.signal("foo.bar.baz") + def sync_signal(*_): + nonlocal counter + + counter += 1 + + @app.signal("foo.bar.baz") + async def async_signal(*_): + nonlocal counter + + counter += 1 + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + assert counter == 2 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_triggers_event(app): + counter = 0 + + @app.signal("foo.bar.baz") + def sync_signal(*args): + nonlocal app + nonlocal counter + signal, *_ = app.signal_router.get("foo.bar.baz") + counter += signal.ctx.event.is_set() + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + signal, *_ = app.signal_router.get("foo.bar.baz") + + assert counter == 1 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_dynamic_route(app): + counter = 0 + + @app.signal("foo.bar.") + def sync_signal(baz): + nonlocal counter + + counter += baz + + app.signal_router.finalize() + + await app.dispatch("foo.bar.9") + assert counter == 9 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_with_requirements(app): + counter = 0 + + @app.signal("foo.bar.baz", condition={"one": "two"}) + def sync_signal(*_): + nonlocal counter + counter += 1 + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + assert counter == 0 + await app.dispatch("foo.bar.baz", condition={"one": "two"}) + assert counter == 1 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_with_context(app): + counter = 0 + + @app.signal("foo.bar.baz") + def sync_signal(amount): + nonlocal counter + counter += amount + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz", context={"amount": 9}) + assert counter == 9 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_with_context_fail(app): + counter = 0 + + @app.signal("foo.bar.baz") + def sync_signal(amount): + nonlocal counter + counter += amount + + app.signal_router.finalize() + + with pytest.raises(TypeError): + await app.dispatch("foo.bar.baz", {"amount": 9}) + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_on_bp(app): + bp = Blueprint("bp") + + app_counter = 0 + bp_counter = 0 + + @app.signal("foo.bar.baz") + def app_signal(): + nonlocal app_counter + app_counter += 1 + + @bp.signal("foo.bar.baz") + def bp_signal(): + nonlocal bp_counter + bp_counter += 1 + + app.blueprint(bp) + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + assert app_counter == 1 + assert bp_counter == 1 + + await bp.dispatch("foo.bar.baz") + assert app_counter == 1 + assert bp_counter == 2 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_event(app): + app_counter = 0 + + @app.signal("foo.bar.baz") + def app_signal(): + ... + + async def do_wait(): + nonlocal app_counter + await app.event("foo.bar.baz") + app_counter += 1 + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + waiter = app.event("foo.bar.baz") + assert isawaitable(waiter) + + fut = asyncio.ensure_future(do_wait()) + await app.dispatch("foo.bar.baz") + await fut + + assert app_counter == 1 + + +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_event_on_bp(app): + bp = Blueprint("bp") + bp_counter = 0 + + @bp.signal("foo.bar.baz") + def bp_signal(): + ... + + async def do_wait(): + nonlocal bp_counter + await bp.event("foo.bar.baz") + bp_counter += 1 + + app.blueprint(bp) + app.signal_router.finalize() + signal, *_ = app.signal_router.get( + "foo.bar.baz", condition={"blueprint": "bp"} + ) + + await bp.dispatch("foo.bar.baz") + waiter = bp.event("foo.bar.baz") + assert isawaitable(waiter) + + fut = asyncio.ensure_future(do_wait()) + signal.ctx.event.set() + await fut + + assert bp_counter == 1 + + +def test_bad_finalize(app): + counter = 0 + + @app.signal("foo.bar.baz") + def sync_signal(amount): + nonlocal counter + counter += amount + + with pytest.raises( + RuntimeError, match="Cannot finalize signals outside of event loop" + ): + app.signal_router.finalize() + + assert counter == 0 + + +def test_event_not_exist(app): + with pytest.raises(NotFound, match="Could not find signal does.not.exist"): + app.event("does.not.exist") + + +def test_event_not_exist_on_bp(app): + bp = Blueprint("bp") + app.blueprint(bp) + + with pytest.raises(NotFound, match="Could not find signal does.not.exist"): + bp.event("does.not.exist") + + +def test_event_on_bp_not_registered(): + bp = Blueprint("bp") + + @bp.signal("foo.bar.baz") + def bp_signal(): + ... + + with pytest.raises( + SanicException, + match=" has not yet been registered to an app", + ): + bp.event("foo.bar.baz")