Skip to content

Commit 1b8128d

Browse files
committed
Tidy up pub/sub handling
This refactors handling of property and action observations. In particular, it: * Introduces a MessageBroker class to handle pub/sub messaging centrally. This eliminates duplicated code from descriptors and should be much clearer. * Adds a `publish` method to the thing server interface for easy publication of events. * No longer errors if events are published before the event loop is active: they are silently ignored. * Removes the option to set properties without emitting an event: this is no longer needed - it was only ever done to suppress errors. * Separates handling of pub/sub messages from websocket protocol considerations. This does not change the websocket protocol. I've updated tests, but have not yet added tests for `MessageBroker`.
1 parent 05560d2 commit 1b8128d

9 files changed

Lines changed: 236 additions & 260 deletions

File tree

src/labthings_fastapi/actions.py

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@
3737
TypeVar,
3838
overload,
3939
)
40-
from weakref import WeakSet
4140
import weakref
4241
from fastapi import APIRouter, FastAPI, HTTPException, Request, Body, BackgroundTasks
4342
from pydantic import BaseModel, create_model
4443

44+
from labthings_fastapi.message_broker import Message
45+
4546

4647
from .middleware.url_for import URLFor
4748
from .base_descriptor import (
@@ -68,7 +69,6 @@
6869
)
6970
from .thing_description import type_to_dataschema
7071
from .thing_description._model import ActionAffordance, ActionOp, Form, LinkElement
71-
from .utilities import labthings_data
7272

7373

7474
if TYPE_CHECKING:
@@ -247,6 +247,20 @@ def response(self) -> InvocationModel:
247247
log=self.log,
248248
)
249249

250+
def _publish_status(self) -> None:
251+
"""Publish a status change event to any observers.
252+
253+
This should be called after each change to ``self._status``
254+
"""
255+
self.thing._thing_server_interface.publish(
256+
Message(
257+
thing=self.thing.name,
258+
affordance=self.action.name, # type: ignore[attr-defined]
259+
message_type="action",
260+
payload=self._status.value,
261+
)
262+
)
263+
250264
def run(self) -> None:
251265
"""Run the action and track progress.
252266
@@ -282,7 +296,7 @@ def run(self) -> None:
282296
add_thing_log_destination(self.id, self._log)
283297
with invocation_contexts.set_invocation_id(self.id):
284298
try:
285-
action.emit_changed_event(self.thing, self._status.value)
299+
self._publish_status()
286300

287301
thing = self.thing
288302
kwargs = model_to_dict(self.input)
@@ -298,21 +312,21 @@ def run(self) -> None:
298312
with self._status_lock:
299313
self._status = InvocationStatus.RUNNING
300314
self._start_time = datetime.datetime.now()
301-
action.emit_changed_event(self.thing, self._status.value)
315+
self._publish_status()
302316

303317
# Actually run the action
304318
ret = action.func(thing, **kwargs, **self.dependencies)
305319

306320
with self._status_lock:
307321
self._return_value = ret
308322
self._status = InvocationStatus.COMPLETED
309-
action.emit_changed_event(self.thing, self._status.value)
323+
self._publish_status()
310324

311325
except InvocationCancelledError:
312326
logger.info(f"Invocation {self.id} was cancelled.")
313327
with self._status_lock:
314328
self._status = InvocationStatus.CANCELLED
315-
action.emit_changed_event(self.thing, self._status.value)
329+
self._publish_status()
316330
except Exception as e: # skipcq: PYL-W0703
317331
# First log
318332
if isinstance(e, InvocationError):
@@ -332,7 +346,7 @@ def run(self) -> None:
332346
with self._status_lock:
333347
self._status = InvocationStatus.ERROR
334348
self._exception = e
335-
action.emit_changed_event(self.thing, self._status.value)
349+
self._publish_status()
336350
finally:
337351
with self._status_lock:
338352
self._end_time = datetime.datetime.now()
@@ -810,70 +824,13 @@ def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]:
810824
"""
811825

812826
@wraps(self.func)
813-
def wrapped(*args: Any, **kwargs: Any) -> Any: # noqa: DOC
827+
def wrapped(*args: Any, **kwargs: Any) -> Any: # noqa: DOC101, DOC103, DOC201
814828
"""Acquire the lock then run `func` with supplied arguments."""
815829
with self.context_for_func(obj):
816830
return self.func(*args, **kwargs)
817831

818832
return partial(wrapped, obj)
819833

820-
def _observers_set(self, obj: Thing) -> WeakSet:
821-
"""Return a set used to notify changes.
822-
823-
Note that we need to supply the `~lt.Thing` we are looking at, as in
824-
general there may be more than one object of the same type, and
825-
descriptor instances are shared between all instances of their class.
826-
827-
:param obj: The `~lt.Thing` on which the action is being observed.
828-
829-
:return: a weak set of callables to notify on changes to the action.
830-
This is used by websocket endpoints.
831-
"""
832-
ld = labthings_data(obj)
833-
if self.name not in ld.action_observers:
834-
ld.action_observers[self.name] = WeakSet()
835-
return ld.action_observers[self.name]
836-
837-
def emit_changed_event(self, obj: Thing, status: str) -> None:
838-
"""Notify subscribers that the action status has changed.
839-
840-
This function is run from within the `.Invocation` thread that
841-
is created when an action is called. It must be run from a thread
842-
as it is communicating with the event loop via an `asyncio` blocking
843-
portal. Async code must not use the blocking portal as it can deadlock
844-
the event loop.
845-
846-
:param obj: The `~lt.Thing` on which the action is being observed.
847-
:param status: The status of the action, to be sent to observers.
848-
"""
849-
obj._thing_server_interface.start_async_task_soon(
850-
self.emit_changed_event_async,
851-
obj,
852-
status,
853-
)
854-
855-
async def emit_changed_event_async(self, obj: Thing, value: Any) -> None:
856-
"""Notify subscribers that the action status has changed.
857-
858-
This is an async function that must be run in the `anyio` event loop.
859-
It will send messages to each observer to notify them that something
860-
has changed.
861-
862-
:param obj: The `~lt.Thing` on which the action is defined.
863-
`.ActionDescriptor` objects are unique to the class, but there may
864-
be more than one `~lt.Thing` attached to a server with the same class.
865-
We use ``obj`` to look up the observers of the current `~lt.Thing`.
866-
:param value: The action status to communicate to the observers.
867-
"""
868-
action_name = self.name
869-
for observer in self._observers_set(obj):
870-
await observer.send(
871-
{
872-
"messageType": "actionStatus",
873-
"data": {"action name": action_name, "status": value},
874-
}
875-
)
876-
877834
def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None:
878835
"""Add this action to a FastAPI app, bound to a particular Thing.
879836

src/labthings_fastapi/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class ReadOnlyPropertyError(AttributeError):
4141
class PropertyNotObservableError(RuntimeError):
4242
"""The property is not observable.
4343
44-
This exception is raised when `~lt.Thing.observe_property` is called with a
44+
This exception is raised when trying to observe
4545
property that is not observable. Currently, only data properties are
4646
observable: functional properties (using a getter/setter) may not be
4747
observed.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Handle pub-sub style events.
2+
3+
Both properties and actions can emit events that may be observed. This module handles
4+
all the pub-sub messaging in LabThings.
5+
"""
6+
7+
from dataclasses import dataclass
8+
from typing import Any, Literal
9+
from weakref import WeakSet
10+
11+
from anyio.abc import ObjectSendStream
12+
13+
14+
@dataclass
15+
class Message:
16+
"""A pub-sub event message.
17+
18+
This is the message that is sent when a property or action generates
19+
an event.
20+
21+
:param thing: The name of the Thing generating the event.
22+
:param affordance: The name of the affordance generating the event.
23+
:param message: The message to send.
24+
"""
25+
26+
thing: str
27+
affordance: str
28+
message_type: Literal["property", "action", "event"]
29+
payload: Any
30+
31+
32+
class MessageBroker:
33+
r"""A class that relays pub/sub messages.
34+
35+
This class takes care of relaying messages to streams that have subscribed to them.
36+
It does not format messages or handle any details of e.g. websocket protocol.
37+
38+
Subscriptions require an `ObjectSendStream[Message]` and each time a `Message`
39+
matching the subscription parameters (``thing`` and ``affordance``) is published,
40+
it will be sent on that stream.
41+
42+
The broker does not validate thing or affordance names: that's up to the code
43+
calling `MessageBroker.subscribe`\ .
44+
"""
45+
46+
def __init__(self) -> None:
47+
"""Initialise the message broker."""
48+
# Note that we use a weak set below, so that when a websocket disconnects,
49+
# its stream is removed automatically.
50+
self._subscriptions: dict[
51+
str, dict[str, WeakSet[ObjectSendStream[Message]]]
52+
] = {}
53+
54+
def subscribe(
55+
self, thing: str, affordance: str, stream: ObjectSendStream[Message]
56+
) -> None:
57+
"""Subscribe to messages from a particular affordance.
58+
59+
Note that this method is not async - it just registers the stream and so
60+
can be run from any thread.
61+
62+
:param thing: The name of the `.Thing` being subscribed to.
63+
:param affordance: The name of the affordance being subscribed to.
64+
:param stream: A stream to send the messages to.
65+
:raises TypeError: if the `thing` argument is not a string.
66+
"""
67+
if not isinstance(thing, str):
68+
raise TypeError(f"The `thing` argument should be a string, not {thing}.")
69+
if thing not in self._subscriptions:
70+
self._subscriptions[thing] = {}
71+
if affordance not in self._subscriptions[thing]:
72+
self._subscriptions[thing][affordance] = WeakSet()
73+
self._subscriptions[thing][affordance].add(stream)
74+
75+
def unsubscribe(
76+
self, thing: str, affordance: str, stream: ObjectSendStream[Message]
77+
) -> None:
78+
"""Unsubscribe a stream from messages from a particular affordance.
79+
80+
:param thing: The name of the `.Thing` being unsubscribed from.
81+
:param affordance: The name of the affordance being unsubscribed from.
82+
:param stream: The stream to unsubscribe.
83+
:raises KeyError: if there is no such subscription.
84+
:raises TypeError: if the `thing` argument is not a string.
85+
"""
86+
if not isinstance(thing, str):
87+
raise TypeError(f"The `thing` argument should be a string, not {thing}.")
88+
try:
89+
self._subscriptions[thing][affordance].discard(stream)
90+
except KeyError as e:
91+
raise e
92+
93+
async def publish(self, message: Message) -> None:
94+
"""Publish a message.
95+
96+
This async method will relay the message to any subscriber streams.
97+
98+
:param message: the message to send.
99+
"""
100+
try:
101+
subscriptions = self._subscriptions[message.thing][message.affordance]
102+
except KeyError:
103+
return # No subscribers for this thing.
104+
for stream in subscriptions:
105+
await stream.send(message)

0 commit comments

Comments
 (0)