Repository: sanic-org/sanic

Issue:
Event registration
When defining a signal, an event is created:

```python
@app.signal("do.something.start")
async def signal_handler():
    ...
```

But, what if you want to dispatch that signal, and then wait for another event:

```python
@app.post("/trigger")
async def trigger(request):
    await app.dispatch("do.something.start")
    await app.event("do.something.complete")
    return text("Done.")
```

Currently, this would fail because we never defined `do.something.complete`. And, I think this is correct. I do not think that `app.event` should start waiting on something that will never happen.

However, we might still want to dispatch `do.something.complete` without needing to have a signal handler. This usage would really facilitate intra-application messaging.

```python
@app.signal("do.something.start")
async def signal_handler():
    await asyncio.sleep(2)
    await app.dispatch("do.something.complete")
```

We need some sort of an API to register a signal with no handler. I think the solution is to allow `add_signal` to have `handler=None`.

```python
app.add_signal(None, "do.something.complete")
```

The easiest way to achieve that would likely be a change something like this:

```python
    def add_signal(
        self,
        handler: Optional[Callable[..., None]],
        event: str,
        condition: Dict[str, Any] = None,
    ):
        if not handler:
            handler = lambda: ...
        self.signal(event=event, condition=condition)(handler)
        return handler
```


Discussion:
Hello. I'd like to give this a shot. Any specific place I should put this?
Awesome! 🎉 

Take a look in `./mixins/signals.py`

Resolving patch:
diff --git a/sanic/app.py b/sanic/app.py
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -382,11 +382,19 @@ def dispatch(
             condition=condition,
         )
 
-    def event(self, event: str, timeout: Optional[Union[int, float]] = None):
+    async 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)
+            if self.config.EVENT_AUTOREGISTER:
+                self.signal_router.reset()
+                self.add_signal(None, event)
+                signal = self.signal_router.name_index.get(event)
+                self.signal_router.finalize()
+            else:
+                raise NotFound("Could not find signal %s" % event)
+        return await wait_for(signal.ctx.event.wait(), timeout=timeout)
 
     def enable_websocket(self, enable=True):
         """Enable or disable the support for websocket.
diff --git a/sanic/config.py b/sanic/config.py
--- a/sanic/config.py
+++ b/sanic/config.py
@@ -16,28 +16,29 @@
 """
 
 DEFAULT_CONFIG = {
-    "REQUEST_MAX_SIZE": 100000000,  # 100 megabytes
+    "ACCESS_LOG": True,
+    "EVENT_AUTOREGISTER": False,
+    "FALLBACK_ERROR_FORMAT": "html",
+    "FORWARDED_FOR_HEADER": "X-Forwarded-For",
+    "FORWARDED_SECRET": None,
+    "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,  # 15 sec
+    "KEEP_ALIVE_TIMEOUT": 5,  # 5 seconds
+    "KEEP_ALIVE": True,
+    "PROXIES_COUNT": None,
+    "REAL_IP_HEADER": None,
+    "REGISTER": True,
     "REQUEST_BUFFER_QUEUE_SIZE": 100,
     "REQUEST_BUFFER_SIZE": 65536,  # 64 KiB
+    "REQUEST_ID_HEADER": "X-Request-ID",
+    "REQUEST_MAX_SIZE": 100000000,  # 100 megabytes
     "REQUEST_TIMEOUT": 60,  # 60 seconds
     "RESPONSE_TIMEOUT": 60,  # 60 seconds
-    "KEEP_ALIVE": True,
-    "KEEP_ALIVE_TIMEOUT": 5,  # 5 seconds
-    "WEBSOCKET_MAX_SIZE": 2 ** 20,  # 1 megabyte
     "WEBSOCKET_MAX_QUEUE": 32,
+    "WEBSOCKET_MAX_SIZE": 2 ** 20,  # 1 megabyte
+    "WEBSOCKET_PING_INTERVAL": 20,
+    "WEBSOCKET_PING_TIMEOUT": 20,
     "WEBSOCKET_READ_LIMIT": 2 ** 16,
     "WEBSOCKET_WRITE_LIMIT": 2 ** 16,
-    "WEBSOCKET_PING_TIMEOUT": 20,
-    "WEBSOCKET_PING_INTERVAL": 20,
-    "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,  # 15 sec
-    "ACCESS_LOG": True,
-    "FORWARDED_SECRET": None,
-    "REAL_IP_HEADER": None,
-    "PROXIES_COUNT": None,
-    "FORWARDED_FOR_HEADER": "X-Forwarded-For",
-    "REQUEST_ID_HEADER": "X-Request-ID",
-    "FALLBACK_ERROR_FORMAT": "html",
-    "REGISTER": True,
 }
 
 
diff --git a/sanic/mixins/signals.py b/sanic/mixins/signals.py
--- a/sanic/mixins/signals.py
+++ b/sanic/mixins/signals.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Set
+from typing import Any, Callable, Dict, Optional, Set
 
 from sanic.models.futures import FutureSignal
 from sanic.models.handler_types import SignalHandler
@@ -60,10 +60,16 @@ def decorator(handler: SignalHandler):
 
     def add_signal(
         self,
-        handler,
+        handler: Optional[Callable[..., Any]],
         event: str,
         condition: Dict[str, Any] = None,
     ):
+        if not handler:
+
+            async def noop():
+                ...
+
+            handler = noop
         self.signal(event=event, condition=condition)(handler)
         return handler
 


Test patch:
diff --git a/tests/test_signals.py b/tests/test_signals.py
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -257,17 +257,60 @@ def sync_signal(amount):
     assert counter == 0
 
 
-def test_event_not_exist(app):
+@pytest.mark.asyncio
+async def test_event_not_exist(app):
     with pytest.raises(NotFound, match="Could not find signal does.not.exist"):
-        app.event("does.not.exist")
+        await app.event("does.not.exist")
 
 
-def test_event_not_exist_on_bp(app):
+@pytest.mark.asyncio
+async 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")
+        await bp.event("does.not.exist")
+
+
+@pytest.mark.asyncio
+async def test_event_not_exist_with_autoregister(app):
+    app.config.EVENT_AUTOREGISTER = True
+    try:
+        await app.event("does.not.exist", timeout=0.1)
+    except asyncio.TimeoutError:
+        ...
+
+
+@pytest.mark.asyncio
+async def test_dispatch_signal_triggers_non_exist_event_with_autoregister(app):
+    @app.signal("some.stand.in")
+    async def signal_handler():
+        ...
+
+    app.config.EVENT_AUTOREGISTER = True
+    app_counter = 0
+    app.signal_router.finalize()
+
+    async def do_wait():
+        nonlocal app_counter
+        await app.event("foo.bar.baz")
+        app_counter += 1
+
+    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_not_exist(app):
+    @app.signal("do.something.start")
+    async def signal_handler():
+        ...
+
+    app.signal_router.finalize()
+    await app.dispatch("does.not.exist")
 
 
 def test_event_on_bp_not_registered():