Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GIT-1630: add basic signal infra #2041

Closed
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sanic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from sanic.__version__ import __version__
from sanic.app import Sanic
from sanic.blueprints import Blueprint

from sanic.request import Request
from sanic.response import HTTPResponse
from sanic import request

__all__ = ["Sanic", "Blueprint", "__version__"]
26 changes: 26 additions & 0 deletions sanic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
SanicException,
ServerError,
URLBuildError,
NotFound,
)
from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType
from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
Expand All @@ -49,6 +50,7 @@
serve,
serve_multiple,
)
from sanic.signals import SignalData
from sanic.websocket import ConnectionClosed, WebSocketProtocol


Expand Down Expand Up @@ -510,6 +512,9 @@ async def handle_request(self, request):
response = None
name = None
try:
await self.publish(
signal="app.route.before", data=SignalData(request=request)
)
# Fetch handler from router
(
handler,
Expand Down Expand Up @@ -577,6 +582,11 @@ async def handle_request(self, request):
except CancelledError:
raise
except Exception as e:
if isinstance(e, NotFound):
await self.publish(
signal="app.route.missing",
data=SignalData(request=request),
)
# Response Generation Failed
await self.handle_exception(request, e)

Expand Down Expand Up @@ -1041,6 +1051,22 @@ def update_config(self, config: Union[bytes, str, dict, Any]):

self.config.update_config(config)

# -------------------------------------------------------------------- #
# Signal Handler
# -------------------------------------------------------------------- #
async def publish(
self, signal: str, data: Union[Dict[str, Any], SignalData]
):
if self.get_signal_context(signal=signal):
await self._signal_registry.dispatch(
context=self.get_signal_context(signal=signal),
data=SignalData(additional_info=data)
if not isinstance(data, SignalData)
else data,
app=self,
loop=self.loop,
)

# -------------------------------------------------------------------- #
# Class methods
# -------------------------------------------------------------------- #
Expand Down
2 changes: 2 additions & 0 deletions sanic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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):
Expand Down Expand Up @@ -31,6 +32,7 @@ class BaseSanic(
MiddlewareMixin,
ListenerMixin,
ExceptionMixin,
SignalMixin,
metaclass=Base,
):
...
8 changes: 8 additions & 0 deletions sanic/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,11 @@ def abort(status_code, message=None):
message = message.decode("utf8")
sanic_exception = _sanic_exceptions.get(status_code, SanicException)
raise sanic_exception(message=message, status_code=status_code)


class SignalsNotFrozenException(SanicException):
pass


class InvalidSignalFormatException(SanicException):
pass
60 changes: 60 additions & 0 deletions sanic/mixins/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import typing as t

from sanic.exceptions import InvalidSignalFormatException
from sanic.signals import SignalRegistry, SignalContext, SignalData


class Singleton(type):
harshanarayana marked this conversation as resolved.
Show resolved Hide resolved
_instances = {}

def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(
*args, **kwargs
)
return cls._instances[cls]


class SignalMixin:
__metaclass__ = Singleton

def __init__(self, *args, **kwargs) -> None:
self._signal_registry = SignalRegistry()
self._ctx_mapper = {} # type: t.Dict[str, SignalContext]
self.name = None

def signal(self, signal: str):
def _wrapper(handler: t.Callable):
parts = signal.split(".")
if len(parts) < 2 or len(parts) > 3:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be we can avoid checking and raise an Index error and be done with it?

raise InvalidSignalFormatException(
message=f"Signal format {signal} is invalid. Supported format is <namespace>.<context>[.<action>]"
)
else:
if not self._ctx_mapper.get(signal):
ctx = SignalContext(
namespace=parts[0],
context=parts[1],
action=parts[2] if len(parts) > 2 else None,
)
self._ctx_mapper[signal] = ctx
self._signal_registry.register(
context=ctx, owner=self.name
)
else:
ctx = self._ctx_mapper[signal]
self._signal_registry.subscribe(context=ctx, callback=handler)
return handler

return _wrapper

def get_signal_context(self, signal: str) -> SignalContext:
return self._ctx_mapper.get(signal)

def freeze(self, signal: t.Union[None, str] = None):
self._signal_registry.freeze(
context=self._ctx_mapper[signal] if signal else None
)

async def publish(self, signal: str, data: t.Dict[str, t.Any]):
raise NotImplementedError
7 changes: 5 additions & 2 deletions sanic/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
from sanic.constants import HTTP_METHODS
from sanic.exceptions import MethodNotSupported, NotFound
from sanic.request import Request

from sanic.mixins.signals import SignalMixin

ROUTER_CACHE_SIZE = 1024


class Router(BaseRouter):
class Router(BaseRouter, SignalMixin):
"""
The router implementation responsible for routing a :class:`Request` object
to the appropriate handler.
"""

def __init__(self, *args, **kwargs):
super(Router, self).__init__(*args, **kwargs)

DEFAULT_METHOD = "GET"
ALLOWED_METHODS = HTTP_METHODS

Expand Down
150 changes: 150 additions & 0 deletions sanic/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import typing as t

from inspect import iscoroutinefunction
from asyncio import AbstractEventLoop
from frozenlist import FrozenList

import sanic


from sanic.exceptions import SignalsNotFrozenException


class Singleton(type):
_instances = {}

def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(
*args, **kwargs
)
return cls._instances[cls]


class SignalContext:
__slots__ = ["_namespace", "_context", "_action"]
harshanarayana marked this conversation as resolved.
Show resolved Hide resolved

def __init__(
self,
namespace: t.AnyStr,
context: t.AnyStr,
action: t.Union[None, t.AnyStr],
):
self._namespace = namespace
self._context = context
self._action = action

def __repr__(self):
signal_name = f"{self._namespace}.{self._context}"
if self._action:
signal_name = f"{signal_name}.{self._action}"
return signal_name


class SignalData:
__slots__ = ["_request", "_response", "_additional_info"]

def __init__(
self,
request: t.Union[None, "sanic.request.Request"] = None,
response: t.Union[None, "sanic.response.HTTPResponse"] = None,
additional_info: t.Dict[t.AnyStr, t.Any] = None,
):
self._request = request
self._response = response
self._additional_info = additional_info

@property
def request(self) -> t.Union[None, "sanic.request.Request"]:
return self._request

@property
def response(self) -> t.Union[None, "sanic.response.HTTPResponse"]:
return self._response

@property
def additional_info(self) -> t.Dict[t.AnyStr, t.Any]:
return self._additional_info

def __repr__(self):
info = ""
if self._request:
info = f"\nRequest(method={self._request.method},url={self._request.url})"

if self._response:
info = f"{info}\nResponse(status={self._response.status})"

if self._additional_info:
info = f"{info}\nAdditional Info({self._additional_info})"

if info:
return info
return self


class Signal(FrozenList):
__slots__ = ("_owner",)

def __init__(self, owner):
super(Signal, self).__init__(items=None)
self._owner = owner

async def dispatch(
self,
app: "sanic.Sanic",
loop: AbstractEventLoop,
signal: SignalContext,
signal_data: SignalData,
):
if not self.frozen:
raise SignalsNotFrozenException(
message=f"Unable to dispatch events on a non-frozen signal set for source {self._owner}"
)

for recipient in self:
if iscoroutinefunction(recipient):
await recipient(app, loop, signal, signal_data)
else:
recipient(app, loop, signal, signal_data)


signal_handler = t.Callable[
[
"sanic.Sanic",
AbstractEventLoop,
SignalContext,
t.Union[None, SignalData],
],
t.Union[None, t.Awaitable[None]],
]


class SignalRegistry(metaclass=Singleton):
def __init__(self):
self._signals_map = {} # type: t.Dict[SignalContext, Signal]

def register(self, context: SignalContext, owner: t.AnyStr):
self._signals_map[context] = Signal(owner=owner)

def subscribe(
self, context: SignalContext, callback: t.Callable[..., signal_handler]
):
self._signals_map[context].append(callback)

async def dispatch(
self,
context: SignalContext,
data: t.Union[None, SignalData] = None,
app: t.Union[None, "sanic.Sanic"] = None,
loop: t.Union[None, AbstractEventLoop] = None,
):
await self._signals_map[context].dispatch(
app=app, loop=loop, signal=context, signal_data=data
)

def freeze(self, context: t.Union[SignalContext, None] = None):
if not context:
for _ctx, _sig in self._signals_map.items():
_sig.freeze()
else:
self._signals_map[context].freeze()
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def open_local(paths, mode="r", encoding="utf8"):
"aiofiles>=0.6.0",
"websockets>=8.1,<9.0",
"multidict>=5.0,<6.0",
"frozenlist==1.1.1",
]

tests_require = [
Expand Down