feat(plugins): public event bridge for plugin backends
A plugin backend pushing an update to its own desktop half imports tui_gateway.server._broadcast_global_event — a core-private whose signature is not a plugin contract. Give plugin authors a public, documented door. hermes_cli.plugin_events.broadcast_plugin_event(plugin_id, event, payload) emits plugin.<plugin_id>.<event> on the app's global event stream (the one host.onEvent subscribes to), with the plugin id forced into the namespace and the bare event name validated — a mangled name would strand the desktop half waiting on a name nobody emits. Fire-and-forget, safe from any request handler. Wishlist item 8 of #116305; unblocks rss-reader (#115972).
This commit is contained in:
67
hermes_cli/plugin_events.py
Normal file
67
hermes_cli/plugin_events.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Public event bridge for plugin backends (``plugin_api.py``).
|
||||
|
||||
A plugin's backend runs inside the gateway process. To push an update to its
|
||||
OWN desktop half it emits on the app's global event stream — the same stream
|
||||
``host.onEvent`` subscribes to in the renderer::
|
||||
|
||||
from hermes_cli.plugin_events import broadcast_plugin_event
|
||||
|
||||
broadcast_plugin_event("rss-reader", "items", {"count": 3})
|
||||
# event "plugin.rss-reader.items" reaches every connected desktop client
|
||||
|
||||
The desktop half filters for its own name::
|
||||
|
||||
host.onEvent('plugin.rss-reader.items', payload => …)
|
||||
|
||||
This module is the sanctioned door for that: plugin backends must never import
|
||||
``tui_gateway.server`` privates (``_broadcast_global_event``), whose signature
|
||||
is core-internal. The ``plugin.`` prefix keeps plugin traffic out of core's own
|
||||
event names (``skin.changed``, ``session.reclaimed``, …).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
#: Every plugin event name starts with this — core owns all other names.
|
||||
PLUGIN_EVENT_PREFIX = "plugin."
|
||||
|
||||
# Plugin ids are the manifest names (lowercase, dashes): ``rss-reader``.
|
||||
_PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
# One event segment: no whitespace, no dots (the dot is the name separator).
|
||||
_EVENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def plugin_event_name(plugin_id: str, event: str) -> str:
|
||||
"""The wire name for one plugin event: ``plugin.<plugin_id>.<event>``.
|
||||
|
||||
Raises ``ValueError`` for an id or event that cannot form one — a silently
|
||||
mangled name would strand the desktop half waiting on a name nobody emits.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not _PLUGIN_ID_RE.match(plugin_id):
|
||||
raise ValueError(f"invalid plugin id {plugin_id!r}: expected [a-z0-9][a-z0-9._-]{{0,63}}")
|
||||
if not isinstance(event, str) or not _EVENT_RE.match(event):
|
||||
raise ValueError(
|
||||
f"invalid plugin event {event!r}: expected one segment of [A-Za-z0-9_-] "
|
||||
"(the plugin id already namespaces the name)"
|
||||
)
|
||||
return f"{PLUGIN_EVENT_PREFIX}{plugin_id}.{event}"
|
||||
|
||||
|
||||
def broadcast_plugin_event(plugin_id: str, event: str, payload: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Emit ``plugin.<plugin_id>.<event>`` to every connected client.
|
||||
|
||||
Fire-and-forget and safe to call from any request handler: delivery fans out
|
||||
over the gateway's live transports, and a wedged peer is skipped rather than
|
||||
stalling the caller. ``payload`` must be a dict (or ``None`` for ``{}``).
|
||||
"""
|
||||
if payload is not None and not isinstance(payload, dict):
|
||||
raise TypeError(f"plugin event payload must be a dict or None, got {type(payload).__name__}")
|
||||
|
||||
# Late import: plugin backends load before the gateway server is up in some
|
||||
# hosts (CLI tooling imports plugin_api modules for route inspection), and
|
||||
# tui_gateway.server pulls in the transport stack.
|
||||
from tui_gateway.server import _broadcast_global_event
|
||||
|
||||
_broadcast_global_event(plugin_event_name(plugin_id, event), dict(payload or {}))
|
||||
61
tests/hermes_cli/test_plugin_events.py
Normal file
61
tests/hermes_cli/test_plugin_events.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Public event bridge for plugin backends (``plugin_api.py``).
|
||||
|
||||
Regression for #116305 item 8: a plugin backend pushes events to its own
|
||||
desktop half through ``hermes_cli.plugin_events`` instead of importing
|
||||
``tui_gateway.server._broadcast_global_event``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import plugin_events
|
||||
|
||||
|
||||
def test_event_name_namespaces_under_the_plugin():
|
||||
assert plugin_events.plugin_event_name("rss-reader", "items") == "plugin.rss-reader.items"
|
||||
|
||||
|
||||
def test_broadcast_reaches_the_global_stream_with_the_namespaced_name(monkeypatch):
|
||||
seen: list[tuple[str, object]] = []
|
||||
|
||||
import tui_gateway.server as server
|
||||
|
||||
monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload)))
|
||||
|
||||
plugin_events.broadcast_plugin_event("rss-reader", "items", {"count": 3})
|
||||
|
||||
assert seen == [("plugin.rss-reader.items", {"count": 3})]
|
||||
|
||||
|
||||
def test_broadcast_defaults_the_payload_to_an_empty_dict(monkeypatch):
|
||||
seen: list[tuple[str, object]] = []
|
||||
|
||||
import tui_gateway.server as server
|
||||
|
||||
monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload)))
|
||||
|
||||
plugin_events.broadcast_plugin_event("kanban", "changed")
|
||||
|
||||
assert seen == [("plugin.kanban.changed", {})]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plugin_id", "event"),
|
||||
[
|
||||
("", "items"),
|
||||
("Bad Id", "items"),
|
||||
("has/slash", "items"),
|
||||
("ok", ""),
|
||||
("ok", "bad name"),
|
||||
("ok", "with.dot"),
|
||||
("ok", "plugin.other.items"),
|
||||
],
|
||||
)
|
||||
def test_invalid_names_raise(plugin_id, event):
|
||||
with pytest.raises(ValueError):
|
||||
plugin_events.plugin_event_name(plugin_id, event)
|
||||
|
||||
|
||||
def test_payload_must_be_a_dict():
|
||||
with pytest.raises(TypeError):
|
||||
plugin_events.broadcast_plugin_event("ok", "items", ["not", "a", "dict"]) # type: ignore[arg-type]
|
||||
@@ -1376,6 +1376,31 @@ never auto-import Python. This is a security boundary, not an oversight
|
||||
(GHSA-mcfc-hp25-cjv7).
|
||||
:::
|
||||
|
||||
#### Pushing events to your desktop half
|
||||
|
||||
Your backend runs inside the gateway process, so it can push an update to your
|
||||
own desktop half over the app's global event stream — the same stream
|
||||
`host.onEvent` subscribes to:
|
||||
|
||||
```python
|
||||
from hermes_cli.plugin_events import broadcast_plugin_event
|
||||
|
||||
broadcast_plugin_event("rss-reader", "items", {"count": 3})
|
||||
# → event "plugin.rss-reader.items" reaches every connected desktop client
|
||||
```
|
||||
|
||||
```javascript
|
||||
host.onEvent('plugin.rss-reader.items', payload => queryClient.invalidateQueries({ queryKey: ['items'] }))
|
||||
```
|
||||
|
||||
The name is always `plugin.<your plugin id>.<event>` — the plugin id namespaces
|
||||
it, so `broadcast_plugin_event` takes the BARE event name (`"items"`, not
|
||||
`"plugin.rss-reader.items"`) and raises on anything else. Delivery is
|
||||
fire-and-forget (a wedged client is skipped, never stalling your handler). Use
|
||||
this instead of importing `tui_gateway.server` internals; for plugin-scoped
|
||||
frames with a payload tailored per connection, `ctx.socket('/events')` remains
|
||||
the richer door.
|
||||
|
||||
### Calling it from the plugin
|
||||
|
||||
```javascript
|
||||
|
||||
Reference in New Issue
Block a user