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

RFC/1630 Signals #2042

Merged
merged 31 commits into from
Mar 14, 2021
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a268087
Temp working version of initial signal api
ahopkins Feb 23, 2021
25b170a
Merge branch 'master' into signals-api
ahopkins Feb 28, 2021
5548acb
Add signals testing
ahopkins Mar 4, 2021
c506f54
fix signals router finalizing
ahopkins Mar 4, 2021
93371c3
merge conflict
ahopkins Mar 4, 2021
93f87a1
Use new sanic-router to resolve app/bp on same signal path
ahopkins Mar 8, 2021
1646a40
Additional tests
ahopkins Mar 8, 2021
fbc5d25
Add event test
ahopkins Mar 8, 2021
e54d7c6
finalize test
ahopkins Mar 8, 2021
ded054f
remove old comment
ahopkins Mar 8, 2021
63a50ba
Add some missing annotations
ahopkins Mar 8, 2021
6feb8e9
multiple apps per BP support
ahopkins Mar 9, 2021
5b3cfba
Merge branch 'master' of github.com:sanic-org/sanic into signals-api
ahopkins Mar 9, 2021
d2fa5e4
deepsource?
ahopkins Mar 9, 2021
6dbc830
rtemove deepsource
ahopkins Mar 9, 2021
b014b29
nominal change
ahopkins Mar 9, 2021
4a1343c
fix blueprints test
ahopkins Mar 9, 2021
c83e7b1
Merge branch 'master' into signals-api
ahopkins Mar 10, 2021
555fb1d
Merge branch 'master' into signals-api
ahopkins Mar 11, 2021
470c9b5
trivial change to trigger build
ahopkins Mar 11, 2021
14cdc7a
Merge branch 'signals-api' of github.com:sanic-org/sanic into signals…
ahopkins Mar 11, 2021
b04733e
signal docstring
ahopkins Mar 11, 2021
53d2d70
Updates from feedback
ahopkins Mar 11, 2021
ccbe4eb
squash
ahopkins Mar 11, 2021
4555b55
squash
ahopkins Mar 11, 2021
4a7252e
Add a couple new tests
ahopkins Mar 11, 2021
821e380
Merge branch 'master' into signals-api
ahopkins Mar 12, 2021
472d121
Add some suggestions from review
ahopkins Mar 13, 2021
4917948
Merge branch 'signals-api' of github.com:sanic-org/sanic into signals…
ahopkins Mar 13, 2021
f0687bb
Remove inaccessible code
ahopkins Mar 14, 2021
5c1e146
Change where to condition
ahopkins Mar 14, 2021
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
12 changes: 0 additions & 12 deletions .deepsource.toml

This file was deleted.

79 changes: 58 additions & 21 deletions sanic/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
import logging.config
import os
Expand All @@ -13,7 +14,9 @@
from traceback import format_exc
from typing import (
Any,
Awaitable,
Callable,
Coroutine,
Deque,
Dict,
Iterable,
Expand All @@ -26,6 +29,7 @@
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
Expand All @@ -48,20 +52,17 @@
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


Expand All @@ -76,10 +77,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,
Expand All @@ -100,6 +102,7 @@ def __init__(
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)
Expand Down Expand Up @@ -162,7 +165,7 @@ def add_task(self, task) -> None:
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
<https://sanicframework.org/guide/basics/tasks.html#background-tasks>`__

:param task: future, couroutine or awaitable
Expand Down Expand Up @@ -309,6 +312,28 @@ def _apply_middleware(
middleware.middleware, middleware.attach_to
)

def _apply_signal(self, signal: FutureSignal) -> Signal:
return self.signal_router.add(*signal)

def dispatch(
self,
event: str,
*,
where: Optional[Dict[str, str]] = None,
context: Optional[Dict[str, Any]] = None,
) -> Coroutine[Any, Any, Awaitable[Any]]:
return self.signal_router.dispatch(
event,
context=context,
where=where,
)

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 asyncio.wait_for(signal.ctx.event.wait(), timeout=timeout)

def enable_websocket(self, enable=True):
"""Enable or disable the support for websocket.

Expand Down Expand Up @@ -382,7 +407,7 @@ def url_for(self, view_name: str, **kwargs):

app.config.SERVER_NAME = "myserver:7777"

`See user guide
`See user guide re: routing
<https://sanicframework.org/guide/basics/routing.html#generating-a-url>`__

:param view_name: string referencing the view name
Expand Down Expand Up @@ -1031,11 +1056,9 @@ def _helper(
):
"""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
Expand Down Expand Up @@ -1064,7 +1087,7 @@ def _helper(
"unix": unix,
"ssl": ssl,
"app": self,
"signal": Signal(),
"signal": ServerSignal(),
"loop": loop,
"register_sys_signals": register_sys_signals,
"backlog": backlog,
Expand Down Expand Up @@ -1159,7 +1182,7 @@ def update_config(self, config: Union[bytes, str, dict, Any]):
"""
Update app.config. Full implementation can be found in the user guide.

`See user guide
`See user guide re: configuration
<https://sanicframework.org/guide/deployment/configuration.html#basics>`__
"""

Expand Down Expand Up @@ -1196,7 +1219,7 @@ def get_app(
'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:
Expand All @@ -1205,3 +1228,17 @@ def get_app(
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
2 changes: 2 additions & 0 deletions sanic/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ async def startup(self) -> None:
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", [])
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,
):
def __str__(self) -> str:
Expand Down
57 changes: 55 additions & 2 deletions sanic/blueprints.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from __future__ import annotations

import asyncio

from collections import defaultdict
from typing import Dict, Iterable, List, Optional
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,
Expand All @@ -13,6 +19,10 @@
)


if TYPE_CHECKING:
from sanic import Sanic # noqa


class Blueprint(BaseSanic):
"""
In *Sanic* terminology, a **Blueprint** is a logical collection of
Expand All @@ -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
<https://sanicframework.org/guide/best-practices/blueprints.html>`__

:param name: unique name of the blueprint
Expand All @@ -40,6 +50,7 @@ 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
Expand Down Expand Up @@ -70,6 +81,14 @@ def __repr__(self) -> str:
)
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)
Expand All @@ -90,6 +109,10 @@ def exception(self, *args, **kwargs):
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):
"""
Expand Down Expand Up @@ -132,6 +155,7 @@ def register(self, app, options):
*url_prefix* - URL Prefix to override the blueprint prefix
"""

self._apps.add(app)
url_prefix = options.get("url_prefix", self.url_prefix)

routes = []
Expand Down Expand Up @@ -200,6 +224,10 @@ def register(self, app, options):
for listener in self._future_listeners:
listeners[listener.event].append(app._apply_listener(listener))

for signal in self._future_signals:
signal.where.update({"blueprint": self.name})
app._apply_signal(signal)

self.routes = [route for route in routes if isinstance(route, Route)]

# Deprecate these in 21.6
Expand All @@ -209,3 +237,28 @@ def register(self, app, options):
self.middlewares = middleware
self.exceptions = exception_handlers
self.listeners = dict(listeners)

async def dispatch(self, *args, **kwargs):
where = kwargs.pop("where", {})
Copy link
Contributor

Choose a reason for hiding this comment

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

This might be nitpicking, but I am not so sure the requirements and where complement each other so well. The names quite don't indicate what they are being used for when you read it for the first time.

when instead of requirements and with instead of where might be a more suitable replacements ?

@app.signal("foo.bar.baz", when={"one": "two"})
await app.dispatch("foo.bar.baz", with={"one": "two"})

Copy link
Contributor

Choose a reason for hiding this comment

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

Or may be needs and provides ?

@app.signal("foo.bar.baz", needs={"one": "two"})
await app.dispatch("foo.bar.baz", provides={"one": "two"})

Especially since with is a reserved keyword, though it has a nice ring to it, it will be bad idea :D

Copy link
Member Author

Choose a reason for hiding this comment

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

Makes sense. The name sort of stuck from the underlying implementation that is meant to be more generalized. I agree we could do a better job here with variable names.

Copy link
Member

Choose a reason for hiding this comment

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

I'd like to see these names changed too. requirements and where are not intuitive to use.
I do like when and with, it makes more sense in context, but would prefer not to use a keyword.

I think @app.signal() should take something like where or when or matches, and app.dispatch() should have something like arguments or with or provides.

Copy link
Member Author

@ahopkins ahopkins Mar 13, 2021

Choose a reason for hiding this comment

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

🤔 They are both set to where right now.

Let me first backup and explain. The requirements attribute on the signal is akin to what the router is using under the hood. Initially, its purpose is to help facilitate virtual host routing:

app.get("/", host="example.com")()

At somepoint, I would like to expand its usage to also include routing based upon cookies, headers, etc. But there also might be a need for requirements to hold more structured meta data. I do not want to get too far ahead, but I also want to allow for flexibility to grow. Hence, it is a dictionary.

For now, we can simply pass through that whole object. At some point, maybe we need to add some logic.

In the current usage in signals, it adds the name of the blueprint to the object both with signal and dispatch.

So, requirements is a vestige of the underlying API. I agree that we can expose another term that makes more sense. However, whatever we choose I want to make sure is clear and unambiguous enough that someone can easily pick up the code and let it be obvious what is happening.


And this is where I am torn. Originally my thought was that they needed to be two terms since the definition on the signal would potentially carry more details and meta information. On the dispatch it is just passing matching criteria.

On the other hand, having two terms can ultimately be confusing. Especially if they are both beginning with w. I like where because it is a familiar construct in query languages. I think matches and provides would be the types of keywords that someone would forget and need to look up every time they want to use it. Was it match or matches? Does that go in signal or dispatch?

I definitely am not a fan of a verb here. I still think where is easiest to remember. And, while I originally like having two names for future flexibility, I do not want to make a design decision on a feature that may or may not come into existence in the future.


I guess that was a long way to say that I am in favor of a single argument still. If I had to rank the proposals:

  1. where
  2. condition
  3. when
  4. extra
  5. with
  • needs
  • provides
  • matches
  • arguments (confusing what this means, especially since we have another keyword that will provide arguments to the handler

Copy link
Member Author

@ahopkins ahopkins Mar 14, 2021

Choose a reason for hiding this comment

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

@sanic-org/core-devs What if we go with condition for both right now:

@app.signal("foo.bar.baz", condition={"one": "two"})
await app.dispatch("foo.bar.baz", condition={"one": "two"})

Then, IF in the future we want to add more logic to the matching, we can add another kwarg:

await app.dispatch("foo.bar.baz", match={
    "conditions": [{"one": "two"}, {"one": "three"}],
    "rules": RouteRules.MATCH_ANY
}

FYI: Don't get bogged down in the exact API of match, it is just meant to be illustrative of the point that it could have a more structured future use case

where.update({"blueprint": self.name})
kwargs["where"] = where
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)
Comment on lines +250 to +255
Copy link
Member

@ashleysommer ashleysommer Mar 13, 2021

Choose a reason for hiding this comment

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

When this BP is registered on multiple apps, does every app need to have a signal in its router? In the current code this will throw an error if any of the apps doesn't have the signal. I think this an editing error, I think the correct logic is:

Suggested change
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)
events = set()
for app in self.apps:
signal = app.signal_router.name_index.get(event)
if signal:
events.add(signal.ctx.event)

Copy link
Member Author

Choose a reason for hiding this comment

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

When you register a BP, all of its signals should be on the app. In the case where there are multiple apps, it should be on all of them. If it is missing from one, well, likely something went wrong somewhere.

I am fine making this change, but it still feels to me like something bad just happened that should be handled here.

Copy link
Member

Choose a reason for hiding this comment

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

Ok, if it was your intention to do it that way, thats fine. It just looked like a copy&paste error when I was looking through the code, like that exception wasn't supposed to be there. (Eg, if the exception is thrown in the for loop, there is no way for the NotFound exception at the end of the function to be hit).

Copy link
Member Author

Choose a reason for hiding this comment

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

I went back and forth on this myself a couple times. I think maybe you are right though that the final exception doesn't quite fit.

That exception would only be hit if there are no registered apps, in which caste the exception NotFound doesn't make sense.

Copy link
Member Author

Choose a reason for hiding this comment

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

And, because we are accessing self.apps as a property, you would get an exception earlier than that before it ever reaches there.

I suppose I was thinking that we needed to catch when events was empty, but I am not seeing how it could be.

Anyone see something else?


if events:
return asyncio.wait(
[event.wait() for event in events],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout,
)

raise NotFound("Could not find signal %s" % event)
2 changes: 1 addition & 1 deletion sanic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class C:

config.update_config(C)

`See user guide
`See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__
"""

Expand Down
4 changes: 4 additions & 0 deletions sanic/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions sanic/mixins/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def listener(
async def before_server_start(app, loop):
...

`See user guide
<https://sanicframework.org/guide/basics/listeners.html#listeners>`_
`See user guide re: listeners
<https://sanicframework.org/guide/basics/listeners.html#listeners>`__

:param event: event to listen to
"""
Expand Down
4 changes: 2 additions & 2 deletions sanic/mixins/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def middleware(
Can either be called as *@app.middleware* or
*@app.middleware('request')*

`See user guide
<https://sanicframework.org/guide/basics/middleware.html>`_
`See user guide re: middleware
<https://sanicframework.org/guide/basics/middleware.html>`__

:param: middleware_or_request: Optional parameter to use for
identifying which type of middleware is being registered.
Expand Down
2 changes: 1 addition & 1 deletion sanic/mixins/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def __init__(self, *args, **kwargs) -> None:
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:
Expand Down
Loading