Skip to content

Commit 1bf88a8

Browse files
authored
Merge pull request #175 from labthings/better-observeproperty-errors
Raise better errors in observe_property, and test them.
2 parents b90162d + 8c27f65 commit 1bf88a8

File tree

4 files changed

+305
-76
lines changed

4 files changed

+305
-76
lines changed

src/labthings_fastapi/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,13 @@ class ReadOnlyPropertyError(AttributeError):
2626
No setter has been defined for this `.FunctionalProperty`, so
2727
it may not be written to.
2828
"""
29+
30+
31+
class PropertyNotObservableError(RuntimeError):
32+
"""The property is not observable.
33+
34+
This exception is raised when `.Thing.observe_property` is called with a
35+
property that is not observable. Currently, only data properties are
36+
observable: functional properties (using a getter/setter) may not be
37+
observed.
38+
"""

src/labthings_fastapi/thing.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121

2222
from pydantic import BaseModel
2323

24-
from .properties import DataProperty, BaseSetting
24+
from .properties import BaseProperty, DataProperty, BaseSetting
2525
from .descriptors import ActionDescriptor
2626
from .thing_description._model import ThingDescription, NoSecurityScheme
2727
from .utilities import class_attributes
2828
from .thing_description import validation
2929
from .utilities.introspection import get_summary, get_docstring
3030
from .websockets import websocket_endpoint
31+
from .exceptions import PropertyNotObservableError
3132

3233

3334
if TYPE_CHECKING:
@@ -347,10 +348,13 @@ def observe_property(self, property_name: str, stream: ObjectSendStream) -> None
347348
:param stream: the stream used to send events.
348349
349350
:raise KeyError: if the requested name is not defined on this Thing.
351+
:raise PropertyNotObservableError: if the property is not observable.
350352
"""
351-
prop = getattr(self.__class__, property_name)
352-
if not isinstance(prop, DataProperty):
353+
prop = getattr(self.__class__, property_name, None)
354+
if not isinstance(prop, BaseProperty):
353355
raise KeyError(f"{property_name} is not a LabThings Property")
356+
if not isinstance(prop, DataProperty):
357+
raise PropertyNotObservableError(f"{property_name} is not observable.")
354358
prop._observers_set(self).add(stream)
355359

356360
def observe_action(self, action_name: str, stream: ObjectSendStream) -> None:
@@ -361,7 +365,7 @@ def observe_action(self, action_name: str, stream: ObjectSendStream) -> None:
361365
362366
:raise KeyError: if the requested name is not defined on this Thing.
363367
"""
364-
action = getattr(self.__class__, action_name)
368+
action = getattr(self.__class__, action_name, None)
365369
if not isinstance(action, ActionDescriptor):
366370
raise KeyError(f"{action_name} is not an LabThings Action")
367371
observers = action._observers_set(self)

src/labthings_fastapi/websockets.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,57 @@
2424
import logging
2525
from fastapi import WebSocket, WebSocketDisconnect
2626
from fastapi.encoders import jsonable_encoder
27-
from typing import TYPE_CHECKING
27+
from typing import TYPE_CHECKING, Literal
28+
from .exceptions import PropertyNotObservableError
2829

2930
if TYPE_CHECKING:
3031
from .thing import Thing
3132

3233

34+
WEBTHING_ERROR_URL = "https://w3c.github.io/web-thing-protocol/errors"
35+
36+
37+
def observation_error_response(
38+
name: str, affordance_type: Literal["action", "property"], exception: Exception
39+
) -> dict[str, str | dict]:
40+
r"""Generate a websocket error response for observing an action or property.
41+
42+
When a websocket client asks to observe a property or action that either
43+
doesn't exist or isn't observable, this function makes a dictionary that
44+
can be returned to the client indicating an error.
45+
46+
:param name: The name of the affordance being observed.
47+
:param affordance_type: The type of the affordance.
48+
:param exception: The error that was raised.
49+
:returns: A dictionary that may be returned to the websocket.
50+
51+
:raises TypeError: if the exception is not a `KeyError`
52+
or `.PropertyNotObservableError`\ .
53+
"""
54+
if isinstance(exception, KeyError):
55+
error = {
56+
"status": "404",
57+
"type": f"{WEBTHING_ERROR_URL}#not-found",
58+
"title": "Not Found",
59+
"detail": f"No {affordance_type} found with the name '{name}'.",
60+
}
61+
elif isinstance(exception, PropertyNotObservableError):
62+
error = {
63+
"status": "403",
64+
"type": f"{WEBTHING_ERROR_URL}#not-observable",
65+
"title": "Not Observable",
66+
"detail": f"Property '{name}' is not observable.",
67+
}
68+
else:
69+
raise TypeError(f"Can't generate an error response for {exception}.")
70+
return {
71+
"messageType": "response",
72+
"operation": f"observe{affordance_type}",
73+
"name": name,
74+
"error": error,
75+
}
76+
77+
3378
async def relay_notifications_to_websocket(
3479
websocket: WebSocket, receive_stream: ObjectReceiveStream
3580
) -> None:
@@ -66,17 +111,23 @@ async def process_messages_from_websocket(
66111
while True:
67112
try:
68113
data = await websocket.receive_json()
69-
if data["messageType"] == "addPropertyObservation":
114+
except WebSocketDisconnect:
115+
await send_stream.aclose()
116+
return
117+
if data["messageType"] == "addPropertyObservation":
118+
try:
70119
for k in data["data"].keys():
71120
thing.observe_property(k, send_stream)
72-
if data["messageType"] == "addActionObservation":
121+
except (KeyError, PropertyNotObservableError) as e:
122+
logging.error(f"Got a bad websocket message: {data}, caused {e!r}.")
123+
await send_stream.send(observation_error_response(k, "property", e))
124+
if data["messageType"] == "addActionObservation":
125+
try:
73126
for k in data["data"].keys():
74127
thing.observe_action(k, send_stream)
75-
except KeyError as e:
76-
logging.error(f"Got a bad websocket message: {data}, caused KeyError({e})")
77-
except WebSocketDisconnect:
78-
await send_stream.aclose()
79-
return
128+
except KeyError as e:
129+
logging.error(f"Got a bad websocket message: {data}, caused {e!r}.")
130+
await send_stream.send(observation_error_response(k, "action", e))
80131

81132

82133
async def websocket_endpoint(thing: Thing, websocket: WebSocket) -> None:

0 commit comments

Comments
 (0)