diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 95b9910f..853bba4d 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -34,20 +34,26 @@ TypeVar, overload, ) -from weakref import WeakSet import weakref from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks from pydantic import BaseModel, create_model +from anyio.abc import ObjectSendStream -from .base_descriptor import BaseDescriptor +from .base_descriptor import ( + BaseDescriptor, + BaseDescriptorInfo, + DescriptorInfoCollection, +) from .logs import add_thing_log_destination from .utilities import model_to_dict, wrap_plain_types_in_rootmodel from .invocations import InvocationModel, InvocationStatus, LogRecordModel from .dependencies.invocation import NonWarningInvocationID +from .events import Message from .exceptions import ( InvocationCancelledError, InvocationError, NoBlobManagerError, + NotBoundToInstanceError, NotConnectedToServerError, ) from .outputs.blob import BlobIOContextDep, blobdata_to_url_ctx @@ -61,7 +67,6 @@ ) from .thing_description import type_to_dataschema from .thing_description._model import ActionAffordance, ActionOp, Form, LinkElement -from .utilities import labthings_data if TYPE_CHECKING: @@ -247,10 +252,10 @@ def response(self, request: Optional[Request] = None) -> InvocationModel: ] # The line below confuses MyPy because self.action **evaluates to** a Descriptor # object (i.e. we don't call __get__ on the descriptor). - return self.action.invocation_model( # type: ignore[call-overload] + return self.action.invocation_model( # type: ignore[attr-defined] status=self.status, id=self.id, - action=self.thing.path + self.action.name, # type: ignore[call-overload] + action=self.thing.path + self.action.name, # type: ignore[attr-defined] href=href, timeStarted=self._start_time, timeCompleted=self._end_time, @@ -290,7 +295,7 @@ def run(self) -> None: """ # self.action evaluates to an ActionDescriptor. This confuses mypy, # which thinks we are calling ActionDescriptor.__get__. - action: ActionDescriptor = self.action # type: ignore[call-overload] + action: ActionDescriptor = self.action # type: ignore[assignment] logger = self.thing.logger # The line below saves records matching our ID to ``self._log`` add_thing_log_destination(self.id, self._log) @@ -445,10 +450,7 @@ def list_invocations( i.response(request=request) for i in self.invocations if thing is None or i.thing == thing - if action is None or i.action == action # type: ignore[call-overload] - # i.action evaluates to an ActionDescriptor, which confuses mypy - it - # thinks we are calling ActionDescriptor.__get__ but this isn't ever - # called. + if action is None or i.action == action ] def expire_invocations(self) -> None: @@ -625,8 +627,68 @@ def delete_invocation(id: uuid.UUID) -> None: OwnerT = TypeVar("OwnerT", bound="Thing") +class ActionInfo( + BaseDescriptorInfo[ + "ActionDescriptor", OwnerT, Callable[ActionParams, ActionReturn] + ], + Generic[OwnerT, ActionParams, ActionReturn], +): + """Convenient access to the metadata of an action.""" + + @property + def response_timeout(self) -> float: + """The time to wait before replying to the HTTP request initiating an action.""" + return self.get_descriptor().response_timeout + + @property + def retention_time(self) -> float: + """How long to retain the action's output for, in seconds.""" + return self.get_descriptor().retention_time + + @property + def input_model(self) -> type[BaseModel]: + """A Pydantic model for the input parameters of an Action.""" + return self.get_descriptor().input_model + + @property + def output_model(self) -> type[BaseModel]: + """A Pydantic model for the output parameters of an Action.""" + return self.get_descriptor().output_model + + @property + def invocation_model(self) -> type[BaseModel]: + """A Pydantic model for an invocation of this action.""" + return self.get_descriptor().invocation_model + + @property + def func(self) -> Callable[Concatenate[OwnerT, ActionParams], ActionReturn]: + """The function that runs the action.""" + return self.get_descriptor().func + + def observe(self, stream: ObjectSendStream[Message]) -> None: + """Observe changes to this property. + + Changes to this property will be sent to the supplied stream. + + :param stream: The stream to which updated values should be sent. + """ + if self.owning_object is None: + msg = "Can't observe action status from an unbound ActionInfo." + raise NotBoundToInstanceError(msg) + self.owning_object._thing_server_interface.subscribe(self.name, stream) + + +class ActionCollection( + DescriptorInfoCollection[OwnerT, ActionInfo], + Generic[OwnerT], +): + """Access to the metadata of each Action.""" + + _descriptorinfo_class = ActionInfo + + class ActionDescriptor( - BaseDescriptor[Callable[ActionParams, ActionReturn]], + BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]], Generic[ActionParams, ActionReturn, OwnerT], ): """Wrap actions to enable them to be run over HTTP. @@ -691,7 +753,7 @@ def __init__( ) self.invocation_model.__name__ = f"{name}_invocation" - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[OwnerT], name: str) -> None: """Ensure the action name matches the function name. It's assumed in a few places that the function name and the @@ -709,7 +771,7 @@ def __set_name__(self, owner: type[Thing], name: str) -> None: f"'{self.func.__name__}'", ) - def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]: + def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]: """Return the function, bound to an object as for a normal method. This currently doesn't validate the arguments, though it may do so @@ -721,27 +783,7 @@ def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]: descriptor. :return: the action function, bound to ``obj``. """ - # `obj` should be of type `OwnerT`, but `BaseDescriptor` currently - # isn't generic in the type of the owning Thing, so we can't express - # that here. - return partial(self.func, obj) # type: ignore[arg-type] - - def _observers_set(self, obj: Thing) -> WeakSet: - """Return a set used to notify changes. - - Note that we need to supply the `.Thing` we are looking at, as in - general there may be more than one object of the same type, and - descriptor instances are shared between all instances of their class. - - :param obj: The `.Thing` on which the action is being observed. - - :return: a weak set of callables to notify on changes to the action. - This is used by websocket endpoints. - """ - ld = labthings_data(obj) - if self.name not in ld.action_observers: - ld.action_observers[self.name] = WeakSet() - return ld.action_observers[self.name] + return partial(self.func, obj) def emit_changed_event(self, obj: Thing, status: str) -> None: """Notify subscribers that the action status has changed. @@ -920,6 +962,15 @@ def action_affordance( output=type_to_dataschema(self.output_model, title=f"{self.name}_output"), ) + def descriptor_info(self, owner: OwnerT | None = None) -> ActionInfo: + """Return an `.ActionInfo` object describing this action. + + The returned object will either refer to the class, or be bound to a particular + instance. If it is bound, more properties will be available - e.g. we will be + able to get the bound function. + """ + return self._descriptor_info(ActionInfo, owner) + @overload def action( diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 35b0436f..6ac5a008 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -1,24 +1,43 @@ -"""A base class for descriptors in LabThings. +r"""A base class for descriptors in LabThings. :ref:`descriptors` are used to describe :ref:`wot_affordances` in LabThings-FastAPI. There is some behaviour common to most of these, and `.BaseDescriptor` centralises the code that implements it. + +`.BaseDescriptor` provides consistent handling of name, title, and description, as +well as implementing the convention that descriptors return themselves when accessed +as class attributes. It also provides `.BaseDescriptor.descriptor_info` to return +an object that may be used to refer to the descriptor (see later). + +`.FieldTypedBaseDescriptor` is a subclass of `.BaseDescriptor` that adds "field typing", +i.e. the ability to determine the type of the descriptor's value from a type annotation +on the class attribute. This is particularly important for :ref:`properties`\ . + +`.BaseDescriptorInfo` is a class that describes a descriptor, optionally bound to an +instance. This allows us to pass around references to descriptors without confusing +type checkers, and without needing to separately pass the instance along with the +descriptor. + +`.DescriptorInfoCollection` is a mapping of descriptor names to `.BaseDescriptorInfo` +objects, and may be used to retrieve all descriptors of a particular type on a +`.Thing`\ . """ from __future__ import annotations import ast import builtins +from collections.abc import Iterator import inspect from itertools import pairwise import textwrap from typing import overload, Generic, Mapping, TypeVar, TYPE_CHECKING from types import MappingProxyType import typing -from weakref import WeakKeyDictionary, ref, ReferenceType +from weakref import WeakKeyDictionary, ref from typing_extensions import Self from .utilities.introspection import get_docstring, get_summary -from .exceptions import MissingTypeError, InconsistentTypeError +from .exceptions import MissingTypeError, InconsistentTypeError, NotBoundToInstanceError if TYPE_CHECKING: from .thing import Thing @@ -26,6 +45,21 @@ Value = TypeVar("Value") """The value returned by the descriptor, when called on an instance.""" +Owner = TypeVar("Owner", bound="Thing") +"""A Thing subclass that owns a descriptor.""" + +Descriptor = TypeVar("Descriptor", bound="BaseDescriptor") +"""The type of a descriptor that's referred to by a `BaseDescriptorInfo` object.""" + +FTDescriptorT = TypeVar("FTDescriptorT", bound="FieldTypedBaseDescriptor") +"""The type of a field typed descriptor.""" + +DescriptorInfoT = TypeVar("DescriptorInfoT", bound="BaseDescriptorInfo") +"""The type of `.BaseDescriptorInfo` returned by a descriptor""" + +OptionallyBoundInfoT = TypeVar("OptionallyBoundInfoT", bound="OptionallyBoundInfo") +"""The type of `OptionallyBoundInfo` returned by a descriptor.""" + class DescriptorNotAddedToClassError(RuntimeError): """Descriptor has not yet been added to a class. @@ -138,7 +172,187 @@ def _set_prop4(self, val): """ -class BaseDescriptor(Generic[Value]): +class OptionallyBoundInfo(Generic[Owner]): + """A class that may be bound to an owning object or to a class.""" + + def __init__(self, obj: Owner | None, cls: type[Owner] | None = None) -> None: + r"""Initialise an `OptionallyBoundInfo` object. + + This initialises the object, optionally binding it to `obj` if it is + not `None`\ . + + :param obj: The object to which this info object is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + + :param cls: The class to which this info object refers. May be omitted + if `obj` is supplied. + + :raises ValueError: if neither `obj` nor `cls` is supplied. + :raises TypeError: if `obj` and `cls` are both supplied, but `obj` is not + an instance of `cls`. Note that `cls` does not have to be equal to + ``obj.__class__``\ , it just has to pass `isinstance`\ . + """ + if cls is None: + if obj is None: + raise ValueError("Either `obj` or `cls` must be supplied.") + cls = obj.__class__ + if obj and not isinstance(obj, cls): + raise TypeError(f"{obj} is not an instance of {cls}.") + self._descriptor_cls = cls + self._bound_to_obj = obj + + @property + def owning_class(self) -> type[Owner]: + """Retrieve the class this info object is describing.""" + return self._descriptor_cls + + @property + def owning_object(self) -> Owner | None: + """Retrieve the object to which this info object is bound, if present.""" + return self._bound_to_obj + + @property + def is_bound(self) -> bool: + """Whether this info object is bound to an instance. + + If this property is `False` then this object refers only to a class. If it + is `True` then we are describing a particular instance. + """ + return self._bound_to_obj is not None + + def owning_object_or_error(self) -> Owner: + """Return the `.Thing` instance to which we are bound, or raise an error. + + This is mostly a convenience function that saves type-checking boilerplate. + + :return: the owning object. + :raises NotBoundToInstanceError: if this object is not bound. + """ + obj = self._bound_to_obj + if obj is None: + raise NotBoundToInstanceError("Can't return the object, as we are unbound.") + return obj + + +class BaseDescriptorInfo( + OptionallyBoundInfo[Owner], + Generic[Descriptor, Owner, Value], +): + r"""A class that describes a `BaseDescriptor`\ . + + This class is used internally by LabThings to describe :ref:`properties`\ , + :ref:`actions`\ , and other attributes of a `.Thing`\ . It's not usually + encountered directly by someone using LabThings, except as a base class for + `.Action`\ , `.Property` and others. + + LabThings uses descriptors to represent the :ref:`affordances` of a `.Thing`\ . + However, passing descriptors around isn't very elegant for two reasons: + + * Holding references to Descriptor objects can confuse static type checkers. + * Descriptors are attached to a *class* but do not know which *object* they + are defined on. + + This class allows the attributes of a descriptor to be accessed, and holds + a reference to the underlying descriptor and its owning class. It may + optionally hold a reference to a `.Thing` instance, in which case it is + said to be "bound". This means there's no need to separately pass the `.Thing` + along with the descriptor, which should help keep things simple in several + places in the code. + """ + + def __init__( + self, descriptor: Descriptor, obj: Owner | None, cls: type[Owner] | None = None + ) -> None: + r"""Initialise an `OptionallyBoundInfo` object. + + This sets up a BaseDescriptorInfo object, describing ``descriptor`` and + optionally bound to ``obj``\ . + + :param descriptor: The descriptor that this object will describe. + :param obj: The object to which this `.BaseDescriptorInfo` is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + :param cls: The class to which we are bound. Only required if ``obj`` is + `None`\ . + + :raises ValueError: if both ``obj`` and ``cls`` are `None`\ . + """ + super().__init__(obj, cls) + self._descriptor_ref = ref(descriptor) + if cls is None: + if obj is None: + raise ValueError("Either `obj` or `cls` must be supplied.") + cls = obj.__class__ + self._descriptor_cls = cls + self._bound_to_obj = obj + + def get_descriptor(self) -> Descriptor: + """Retrieve the descriptor object. + + :return: The descriptor object + :raises RuntimeError: if the descriptor was garbage collected. This should + never happen. + """ + descriptor = self._descriptor_ref() + if descriptor is None: + msg = "A descriptor was deleted too early. This may be a LabThings Bug." + raise RuntimeError(msg) + return descriptor + + @property + def name(self) -> str: + """The name of the descriptor. + + This should be the same as the name of the attribute in Python. + """ + return self.get_descriptor().name + + @property + def title(self) -> str: + """The title of the descriptor.""" + return self.get_descriptor().title + + @property + def description(self) -> str | None: + """A description (usually the docstring) of the descriptor.""" + return self.get_descriptor().description + + def get(self) -> Value: + """Get the value of the descriptor. + + This method only works on a bound info object, it will raise an error + if called via a class rather than a `.Thing` instance. + + :return: the value of the descriptor. + :raises NotBoundToInstanceError: if called on an unbound object. + """ + if not self.is_bound: + msg = f"We can't get the value of {self.name} when called on a class." + raise NotBoundToInstanceError(msg) + descriptor = self.get_descriptor() + return descriptor.__get__(self.owning_object_or_error()) + + def set(self, value: Value) -> None: + """Set the value of the descriptor. + + This method may only be called if the DescriptorInfo object is bound to a + `.Thing` instance. It will raise an error if called on a class. + + :param value: the new value. + + :raises NotBoundToInstanceError: if called on an unbound info object. + """ + if not self.is_bound: + msg = f"We can't set the value of {self.name} when called on a class." + raise NotBoundToInstanceError(msg) + descriptor = self.get_descriptor() + descriptor.__set__(self.owning_object_or_error(), value) + + +class BaseDescriptor(Generic[Owner, Value]): r"""A base class for descriptors in LabThings-FastAPI. This class implements several behaviours common to descriptors in LabThings: @@ -184,7 +398,7 @@ def __init__(self) -> None: self._set_name_called: bool = False self._owner_name: str = "" - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[Owner], name: str) -> None: r"""Take note of the name to which the descriptor is assigned. This is called when the descriptor is assigned to an attribute of a class. @@ -213,6 +427,7 @@ def __set_name__(self, owner: type[Thing], name: str) -> None: self._set_name_called = True self._name = name self._owner_name = owner.__qualname__ + self._owner_ref = ref(owner) # Check for docstrings on the owning class, and retrieve the one for # this attribute (identified by `name`). @@ -306,12 +521,12 @@ def description(self) -> str | None: # I have ignored D105 (missing docstrings) on the overloads - these should not # exist on @overload definitions. @overload - def __get__(self, obj: Thing, type: type | None = None) -> Value: ... + def __get__(self, obj: Owner, type: type | None = None) -> Value: ... @overload def __get__(self, obj: None, type: type) -> Self: ... - def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self: + def __get__(self, obj: Owner | None, type: type | None = None) -> Value | Self: """Return the value or the descriptor, as per `property`. If ``obj`` is ``None`` (i.e. the descriptor is accessed as a class attribute), @@ -331,7 +546,7 @@ def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self: return self.instance_get(obj) return self - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Return the value of the descriptor. This method is called from ``__get__`` if the descriptor is accessed as an @@ -356,8 +571,84 @@ def instance_get(self, obj: Thing) -> Value: "See BaseDescriptor.__instance_get__ for details." ) + def __set__(self, obj: Owner, value: Value) -> None: + """Mark the `BaseDescriptor` as a data descriptor. + + Even for read-only descriptors, it's important to define a ``__set__`` method. + The presence of this method prevents Python overwriting the descriptor when + a value is assigned. This base implementation returns an `AttributeError` to + signal that the descriptor is read-only. Overriding it with a method that + does not raise an exception will allow the descriptor to be written to. + + :param obj: The object on which to set the value. + :param value: The value to set the descriptor to. + :raises AttributeError: always, as this is read-only by default. + """ + raise AttributeError("This attribute is read-only.") + + def _descriptor_info( + self, info_class: type[DescriptorInfoT], obj: Owner | None = None + ) -> DescriptorInfoT: + """Return a `BaseDescriptorInfo` object for this descriptor. + + The return value of this function is an object that may be passed around + without confusing type checkers, but still allows access to all of its + functionality. Essentially, it just misses out ``__get__`` so that it + is no longer a Descriptor. + + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param info_class: the `.BaseDescriptorInfo` subclass to return. + :param obj: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + :raises RuntimeError: if garbage collection occurs unexpectedly. This + should not happen and would indicate a LabThings bug. + """ + if obj: + return info_class(self, obj) + else: + self.assert_set_name_called() + owning_class = self._owner_ref() + if owning_class is None: + raise RuntimeError("Class was unexpetedly deleted") + return info_class(self, None, owning_class) + + def descriptor_info( + self, owner: Owner | None = None + ) -> BaseDescriptorInfo[Self, Owner, Value]: + """Return a `BaseDescriptorInfo` object for this descriptor. + + This generates an object that refers to the descriptor, optionally + bound to a particular object. It's intended to make it easier to pass + around references to particular affordances, without needing to retrieve + and store Descriptor objects directly (which gets confusing). + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param owner: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + """ + return self._descriptor_info(BaseDescriptorInfo, owner) + + +class FieldTypedBaseDescriptorInfo( + BaseDescriptorInfo[FTDescriptorT, Owner, Value], + Generic[FTDescriptorT, Owner, Value], +): + r"""A description of a `.FieldTypedBaseDescriptor`\ . + + This adds `value_type` to `.BaseDescriptorInfo` so we can fully describe a + `.FieldTypedBaseDescriptor`\ . + """ + + @property + def value_type(self) -> type[Value]: + """The type of the descriptor's value.""" + return self.get_descriptor().value_type + -class FieldTypedBaseDescriptor(Generic[Value], BaseDescriptor[Value]): +class FieldTypedBaseDescriptor(Generic[Owner, Value], BaseDescriptor[Owner, Value]): """A BaseDescriptor that determines its type like a dataclass field.""" def __init__(self) -> None: @@ -374,12 +665,8 @@ def __init__(self) -> None: self._unevaluated_type_hint: str | None = None # Set in `__set_name__` # Type hints are not un-stringized in `__set_name__` but we remember them # for later evaluation in `value_type`. - self._owner: ReferenceType[type] | None = None # For forward-reference types - # When we evaluate the type hints in `value_type` we need a reference to - # the object on which they are defined, to provide the context for the - # evaluation. - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[Owner], name: str) -> None: r"""Take note of the name and type. This function is where we determine the type of the property. It may @@ -431,7 +718,7 @@ class MyThing(Thing): # __orig_class__ is set on generic classes when they are instantiated # with a subscripted type. It is not available during __init__, which # is why we check for it here. - self._type = typing.get_args(self.__orig_class__)[0] + self._type = typing.get_args(self.__orig_class__)[1] if isinstance(self._type, typing.ForwardRef): raise MissingTypeError( f"{owner}.{name} is a subscripted descriptor, where the " @@ -454,7 +741,6 @@ class MyThing(Thing): f"with the inferred type of {self._type}." ) self._unevaluated_type_hint = field_annotation - self._owner = ref(owner) # Ensure a type is specified. # If we've not set _type by now, we are not going to set it, and the @@ -485,13 +771,14 @@ def value_type(self) -> type[Value]: self.assert_set_name_called() if self._type is None and self._unevaluated_type_hint is not None: # We have a forward reference, so we need to resolve it. - if self._owner is None: + if self._owner_ref is None: raise MissingTypeError( f"Can't resolve forward reference for type of {self.name} because " "the class on which it was defined wasn't saved. This is a " "LabThings bug - please report it." ) - owner = self._owner() + # `self._owner_ref` is set in `BaseDescriptor.__set_name__`. + owner = self._owner_ref() if owner is None: raise MissingTypeError( f"Can't resolve forward reference for type of {self.name} because " @@ -526,6 +813,145 @@ def value_type(self) -> type[Value]: return self._type + def descriptor_info( + self, owner: Owner | None = None + ) -> FieldTypedBaseDescriptorInfo[Self, Owner, Value]: + """Return a `BaseDescriptorInfo` object for this descriptor. + + This generates an object that refers to the descriptor, optionally + bound to a particular object. It's intended to make it easier to pass + around references to particular affordances, without needing to retrieve + and store Descriptor objects directly (which gets confusing). + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param owner: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + """ + return self._descriptor_info(FieldTypedBaseDescriptorInfo, owner) + + +class DescriptorInfoCollection( + Mapping[str, DescriptorInfoT], + OptionallyBoundInfo[Owner], + Generic[Owner, DescriptorInfoT], +): + """Easy access to DescriptorInfo objects of a particular type. + + This class works as a Mapping, so you can retrieve individual + `.DescriptorInfo` objects by name, or iterate over the names of + the descriptors. + + It may be initialised with an object, in which case the contained + `.DescriptorInfo` objects will be bound to that object. If initialised + without an object, the contained `.DescriptorInfo` objects will be + unbound, i.e. referring only to the class. + + This class is subclassed by each of the LabThings descriptors + (Properties, Actions, etc.) and generated by a corresponding + `.OptionallyBoundDescriptor` on `.Thing` for convenience. + """ + + def __init__( + self, + obj: Owner | None, + cls: type[Owner] | None = None, + ) -> None: + r"""Initialise the DescriptorInfoCollection. + + This initialises the object, optionally binding it to `obj` if it is + not `None`\ . + + :param obj: The object to which this info object is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + + :param cls: The class to which this info object refers. May be omitted + if `obj` is supplied. + """ + super().__init__(obj, cls) + + _descriptorinfo_class: type[DescriptorInfoT] + """The class of DescriptorInfo objects contained in this collection. + + This class attribute must be set in subclasses. + """ + + @property + def descriptorinfo_class(self) -> type[DescriptorInfoT]: + """The class of DescriptorInfo objects contained in this collection.""" + return self._descriptorinfo_class + + def __getitem__(self, key: str) -> DescriptorInfoT: + """Retrieve a DescriptorInfo object given the name of the descriptor. + + :param key: The name of the descriptor whose info object is required. + :return: The DescriptorInfo object for the named descriptor. + :raises KeyError: if the key does not refer to a descriptor of the right + type. + """ + attr = getattr(self.owning_class, key, None) + if isinstance(attr, BaseDescriptor): + info = attr.descriptor_info(self.owning_object) + if isinstance(info, self.descriptorinfo_class): + return info + # Attributes that are missing or of the wrong type are not present in + # the mapping, so they raise KeyError. + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + """Iterate over the names of the descriptors of the specified type. + + :yield: The names of the descriptors. + """ + for name, member in inspect.getmembers(self.owning_class): + if isinstance(member, BaseDescriptor): + if isinstance(member.descriptor_info(), self._descriptorinfo_class): + yield name + + def __len__(self) -> int: + """Return the number of descriptors of the specified type. + + :return: The number of descriptors of the specified type. + """ + return sum(1 for _ in self.__iter__()) + + +class OptionallyBoundDescriptor(Generic[Owner, OptionallyBoundInfoT]): + """A descriptor that will return an OptionallyBoundInfo object. + + This descriptor will return an instance of a particular class, initialised + with either the object, or its class, depending on how it is accessed. + + This is useful for returning collections of `.BaseDescriptorInfo` objects + from a `.Thing` subclass. + """ + + def __init__(self, cls: type[OptionallyBoundInfoT]) -> None: + """Initialise the descriptor. + + :param cls: The class of `.OptionallyBoundInfo` objects that this descriptor + will return. + """ + super().__init__() + self._cls = cls + + def __get__( + self, + obj: Owner | None, + cls: type[Owner] | None = None, + ) -> OptionallyBoundInfoT: + """Return an OptionallyBoundInfo object. + + :param obj: The object to which the info is bound, or `None` + if unbound. + :param cls: The class on which the info is defined. + + :return: An `OptionallyBoundInfo` object. + """ + return self._cls(obj, cls) + # get_class_attribute_docstrings is a relatively expensive function that # will be called potentially quite a few times on the same class. It will diff --git a/src/labthings_fastapi/events.py b/src/labthings_fastapi/events.py new file mode 100644 index 00000000..258435be --- /dev/null +++ b/src/labthings_fastapi/events.py @@ -0,0 +1,93 @@ +"""Handle pub-sub style events. + +Both properties and actions can emit events that may be observed. This module handles +all the pub-sub messaging in LabThings. + +This module defines models for the messages sent over websockets, which are aligned with +the ``webthingprotocol`` subprotocol as set out in the `community group draft report`_. + +.. _community group draft report: https://w3c.github.io/web-thing-protocol/ +""" + +from dataclasses import dataclass +from typing import Any, Literal +from weakref import WeakSet + +from anyio.abc import ObjectSendStream + + +@dataclass +class Message: + """A pub-sub event message. + + This is the message that is sent when a property or action generates + an event. + + :param thing: The name of the Thing generating the event. + :param affordance: The name of the affordance generating the event. + :param message: The message to send. + """ + + thing: str + affordance: str + message_type: Literal["property", "action", "event"] + payload: Any + + +class MessageBroker: + """A class that relays pub/sub messages.""" + + def __init__(self) -> None: + """Initialise the message broker.""" + # Note that we use a weak set below, so that when a websocket disconnects, + # its stream is removed automatically. + self._subscriptions: dict[ + str, dict[str, WeakSet[ObjectSendStream[Message]]] + ] = {} + + def subscribe( + self, thing: str, affordance: str, stream: ObjectSendStream[Message] + ) -> None: + """Subscribe to messages from a particular affordance. + + Note that this method is not async - it just registers the stream and so + can be run from any thread. + + :param thing: The name of the `.Thing` being subscribed to. + :param affordance: The name of the affordance being subscribed to. + :param stream: A stream to send the messages to. + """ + if thing not in self._subscriptions: + self._subscriptions[thing] = {} + if affordance not in self._subscriptions[thing]: + self._subscriptions[thing][affordance] = WeakSet() + self._subscriptions[thing][affordance].add(stream) + + def unsubscribe( + self, thing: str, affordance: str, stream: ObjectSendStream[Message] + ) -> None: + """Unsubscribe a stream from messages from a particular affordance. + + :param thing: The name of the `.Thing` being unsubscribed from. + :param affordance: The name of the affordance being unsubscribed from. + :param stream: The stream to unsubscribe. + :raises KeyError: if there is no such subscription. + """ + try: + self._subscriptions[thing][affordance].discard(stream) + except KeyError as e: + raise e + + async def publish(self, message: Message) -> None: + """Publish a message. + + :param thing: the name of the `.Thing` we are publishing about. + :param affordance: the name of the affordance generating the message. + :param message: the message to send. + """ + try: + subscriptions = self._subscriptions[message.thing][message.affordance] + except KeyError: + return # No subscribers for this thing. + for stream in subscriptions: + await stream.send(message) diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 0cbcf419..24957305 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -189,3 +189,16 @@ class ServerActionError(RuntimeError): class ClientPropertyError(RuntimeError): """Setting or getting a property via a ThingClient failed.""" + + +class NotBoundToInstanceError(RuntimeError): + """A `.BaseDescriptorInfo` is not bound to an object. + + Some methods and properties of `.BaseDescriptorInfo` objects require them + to be bound to a `.Thing` instance. If these methods are called on a + `.BaseDescriptorInfo` object that is unbound, this exception is raised. + + This exception should only be seen when `.BaseDescriptorInfo` objects are + generated from a `.Thing` class. Usually, they should be accessed via a + `.Thing` instance, in which case they will be bound. + """ diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 5be2998e..8f213841 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -54,16 +54,15 @@ class attribute. Documentation is in strings immediately following the Any, Callable, Generic, - TypeAlias, TypeVar, overload, TYPE_CHECKING, ) from typing_extensions import Self -from weakref import WeakSet from fastapi import Body, FastAPI -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, ConfigDict, RootModel, create_model +from anyio.abc import ObjectSendStream from .thing_description import type_to_dataschema from .thing_description._model import ( @@ -72,11 +71,18 @@ class attribute. Documentation is in strings immediately following the PropertyAffordance, PropertyOp, ) -from .utilities import labthings_data, wrap_plain_types_in_rootmodel +from .utilities import wrap_plain_types_in_rootmodel from .utilities.introspection import return_type -from .base_descriptor import FieldTypedBaseDescriptor +from .base_descriptor import ( + DescriptorInfoCollection, + FieldTypedBaseDescriptor, + FieldTypedBaseDescriptorInfo, +) +from .events import Message from .exceptions import ( + NotBoundToInstanceError, NotConnectedToServerError, + PropertyNotObservableError, ReadOnlyPropertyError, MissingTypeError, UnsupportedConstraintError, @@ -133,29 +139,19 @@ class MissingDefaultError(ValueError): Value = TypeVar("Value") -if TYPE_CHECKING: - # It's hard to type check methods, because the type of ``self`` - # will be a subclass of `.Thing`, and `Callable` types are - # contravariant in their arguments (i.e. - # ``Callable[[SpecificThing,], Value])`` is not a subtype of - # ``Callable[[Thing,], Value]``. - # It is probably not particularly important for us to check th - # type of ``self`` when decorating methods, so it is left as - # ``Any`` to avoid the major confusion that would result from - # trying to type it more tightly. - # - # Note: in ``@overload`` definitions, it's sometimes necessary - # to avoid the use of these aliases, as ``mypy`` can't - # pick which variant is in use without the explicit `Callable`. - ValueFactory: TypeAlias = Callable[[], Value] - ValueGetter: TypeAlias = Callable[[Any], Value] - ValueSetter: TypeAlias = Callable[[Any, Value], None] +"""The value returned by a property.""" + +Owner = TypeVar("Owner", bound="Thing") +"""The `.Thing` instance on which a property is bound.""" + +BasePropertyT = TypeVar("BasePropertyT", bound="BaseProperty") +"""An instance of (a subclass of) BaseProperty.""" def default_factory_from_arguments( default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, -) -> ValueFactory: + default_factory: Callable[[], Value] | None = None, +) -> Callable[[], Value]: """Process default arguments to get a default factory function. This function takes the ``default`` and ``default_factory`` arguments @@ -198,9 +194,11 @@ def default_factory_from_arguments( raise OverspecifiedDefaultError() if default is not ...: - def default_factory() -> Value: + def dummy_default_factory() -> Value: return default + default_factory = dummy_default_factory + if not callable(default_factory): raise MissingDefaultError("The default_factory must be callable.") return default_factory @@ -209,8 +207,8 @@ def default_factory() -> Value: # See comment at the top of the file regarding ignored linter rules. @overload # use as a decorator @property def property( - getter: Callable[[Any], Value], -) -> FunctionalProperty[Value]: ... + getter: Callable[[Owner], Value], +) -> FunctionalProperty[Owner, Value]: ... @overload # use as `field: int = property(default=0)` @@ -226,13 +224,13 @@ def property( def property( - getter: ValueGetter | EllipsisType = ..., + getter: Callable[[Owner], Value] | EllipsisType = ..., *, default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, **constraints: Any, -) -> Value | FunctionalProperty[Value]: +) -> Value | FunctionalProperty[Owner, Value]: r"""Define a Property on a `.Thing`\ . This function may be used to define :ref:`properties` in @@ -328,7 +326,7 @@ def property( ) -class BaseProperty(FieldTypedBaseDescriptor[Value], Generic[Value]): +class BaseProperty(FieldTypedBaseDescriptor[Owner, Value], Generic[Owner, Value]): """A descriptor that marks Properties on Things. This class is used to determine whether an attribute of a `.Thing` should @@ -396,7 +394,40 @@ def model(self) -> type[BaseModel]: ) return self._model - def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: + def value_to_model(self, value: Value) -> BaseModel: + """Convert a property value to its Pydantic model representation. + + :param value: the property value. + :return: the property value wrapped in its Pydantic model. + """ + if isinstance(value, BaseModel): + return value + else: + # If the return value isn't a model, we need to wrap it in a RootModel + # which we do using the model in self.model + cls = self.model + if not issubclass(cls, RootModel): + msg = ( + f"LabThings couldn't wrap the return value of {self.name} in " + f"a model. This either means your property has an incorrect " + f"type, or there is a bug in LabThings.\n\n" + f"Value: {value}\n" + f"Expected type: {self.value_type}\n" + f"Actual type: {type(value)}\n" + f"Model: {self.model}\n" + ) + raise TypeError(msg) + return cls(root=value) + + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. + + :raises NotImplementedError: as this must be implemented by subclasses. + """ + raise NotImplementedError("observable must be implemented by subclasses.") + + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. :param app: The FastAPI application we are adding endpoints to. @@ -487,7 +518,7 @@ def property_affordance( } ) - def __set__(self, obj: Thing, value: Any) -> None: + def __set__(self, obj: Owner, value: Any) -> None: """Set the property (stub method). This is a stub ``__set__`` method to mark this as a data descriptor. @@ -500,8 +531,19 @@ def __set__(self, obj: Thing, value: Any) -> None: "__set__ must be overridden by property implementations." ) + def descriptor_info( + self, owner: Owner | None = None + ) -> PropertyInfo[Self, Owner, Value]: + r"""Return an object that allows access to this descriptor's metadata. + + :param owner: An instance to bind the descriptor info to. If `None`\ , + the returned object will be unbound and will only refer to the class. + :return: A `PropertyInfo` instance describing this property. + """ + return PropertyInfo(self, owner, self._owner_ref()) + -class DataProperty(BaseProperty[Value], Generic[Value]): +class DataProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): """A Property descriptor that acts like a regular variable. `.DataProperty` descriptors remember their value, and can be read and @@ -521,7 +563,7 @@ def __init__( # noqa: DOC101,DOC103 def __init__( # noqa: DOC101,DOC103 self, *, - default_factory: ValueFactory, + default_factory: Callable[[], Value], readonly: bool = False, constraints: Mapping[str, Any] | None = None, ) -> None: ... @@ -530,7 +572,7 @@ def __init__( self, default: Value | EllipsisType = ..., *, - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, constraints: Mapping[str, Any] | None = None, ) -> None: @@ -573,7 +615,7 @@ def __init__( ) self.readonly = readonly - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Return the property's value. This will supply a default if the property has not yet been set. @@ -588,7 +630,7 @@ def instance_get(self, obj: Thing) -> Value: return obj.__dict__[self.name] def __set__( - self, obj: Thing, value: Value, emit_changed_event: bool = True + self, obj: Owner, value: Value, emit_changed_event: bool = True ) -> None: """Set the property's value. @@ -600,24 +642,9 @@ def __set__( """ obj.__dict__[self.name] = value if emit_changed_event: - self.emit_changed_event(obj, value) - - def _observers_set(self, obj: Thing) -> WeakSet: - """Return the observers of this property. - - Each observer in this set will be notified when the property is changed. - See ``.DataProperty.emit_changed_event`` - - :param obj: the `.Thing` to which we are attached. - - :return: the set of observers corresponding to ``obj``. - """ - ld = labthings_data(obj) - if self.name not in ld.property_observers: - ld.property_observers[self.name] = WeakSet() - return ld.property_observers[self.name] + self.publish_change(obj, self.value_to_model(value)) - def emit_changed_event(self, obj: Thing, value: Value) -> None: + def publish_change(self, obj: Thing, value: BaseModel) -> None: """Notify subscribers that the property has changed. This function is run when properties are updated. It must be run from @@ -626,34 +653,27 @@ def emit_changed_event(self, obj: Thing, value: Value) -> None: thread as it is communicating with the event loop via an `asyncio` blocking portal and can cause deadlock if run in the event loop. - This method will raise a `.ServerNotRunningError` if the event loop is not - running, and should only be called after the server has started. + This method will silently do nothing if the event loop is not running. :param obj: the `.Thing` to which we are attached. :param value: the new property value, to be sent to observers. """ - obj._thing_server_interface.start_async_task_soon( - self.emit_changed_event_async, - obj, - value, + obj._thing_server_interface.publish( + Message( + thing=obj.name, + affordance=self.name, + message_type="property", + payload=value, + ) ) - async def emit_changed_event_async(self, obj: Thing, value: Value) -> None: - """Notify subscribers that the property has changed. - - This function may only be run in the `anyio` event loop. See - `.DataProperty.emit_changed_event`. - - :param obj: the `.Thing` to which we are attached. - :param value: the new property value, to be sent to observers. - """ - for observer in self._observers_set(obj): - await observer.send( - {"messageType": "propertyStatus", "data": {self._name: value}} - ) + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. Always True for DataProperty.""" + return True -class FunctionalProperty(BaseProperty[Value], Generic[Value]): +class FunctionalProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): """A property that uses a getter and a setter. For properties that should work like variables, use `.DataProperty`. For @@ -665,7 +685,7 @@ class FunctionalProperty(BaseProperty[Value], Generic[Value]): def __init__( self, - fget: ValueGetter, + fget: Callable[[Owner], Value], constraints: Mapping[str, Any] | None = None, ) -> None: """Set up a FunctionalProperty. @@ -683,7 +703,7 @@ def __init__( :raises MissingTypeError: if the getter does not have a return type annotation. """ super().__init__(constraints=constraints) - self._fget: ValueGetter = fget + self._fget = fget self._type = return_type(self._fget) if self._type is None: msg = ( @@ -691,20 +711,20 @@ def __init__( "Return type annotations are required for property getters." ) raise MissingTypeError(msg) - self._fset: ValueSetter | None = None + self._fset: Callable[[Owner, Value], None] | None = None self.readonly: bool = True @builtins.property - def fget(self) -> ValueGetter: # noqa: DOC201 + def fget(self) -> Callable[[Owner], Value]: # noqa: DOC201 """The getter function.""" return self._fget @builtins.property - def fset(self) -> ValueSetter | None: # noqa: DOC201 + def fset(self) -> Callable[[Owner, Value], None] | None: # noqa: DOC201 """The setter function.""" return self._fset - def getter(self, fget: ValueGetter) -> Self: + def getter(self, fget: Callable[[Owner], Value]) -> Self: """Set the getter function of the property. This function returns the descriptor, so it may be used as a decorator. @@ -718,7 +738,7 @@ def getter(self, fget: ValueGetter) -> Self: self.__doc__ = fget.__doc__ return self - def setter(self, fset: ValueSetter) -> Self: + def setter(self, fset: Callable[[Owner, Value], None]) -> Self: r"""Set the setter function of the property. This function returns the descriptor, so it may be used as a decorator. @@ -790,7 +810,7 @@ def _set_myprop(self, val: int) -> None: return fset # type: ignore[return-value] return self - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Get the value of the property. :param obj: the `.Thing` on which the attribute is accessed. @@ -798,7 +818,7 @@ def instance_get(self, obj: Thing) -> Value: """ return self.fget(obj) - def __set__(self, obj: Thing, value: Value) -> None: + def __set__(self, obj: Owner, value: Value) -> None: """Set the value of the property. :param obj: the `.Thing` on which the attribute is accessed. @@ -810,11 +830,104 @@ def __set__(self, obj: Thing, value: Value) -> None: raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.") self.fset(obj, value) + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. Always False for FunctionalProperty.""" + return False + + +class PropertyInfo( + FieldTypedBaseDescriptorInfo[BasePropertyT, Owner, Value], + Generic[BasePropertyT, Owner, Value], +): + """Access to the metadata of a Property. + + This class provides a way to access the metadata of a Property, without + needing to retrieve the Descriptor object directly. It may be bound to a + `.Thing` instance, or may be accessed from the class. + """ + + @builtins.property + def model(self) -> type[BaseModel]: # noqa: DOC201 + """A `pydantic.BaseModel` describing this property's value.""" + return self.get_descriptor().model + + @builtins.property + def model_instance(self) -> BaseModel: # noqa: DOC201 + """An instance of ``self.model`` populated with the current value. + + :raises TypeError: if the return value can't be wrapped in a model. + """ + return self.get_descriptor().value_to_model(self.get()) + + def model_to_value(self, value: BaseModel) -> Value: + r"""Convert a model to a value for this property. + + Even properties with plain types are sometimes converted to or from a + `pydantic.BaseModel` to allow conversion to/from JSON. This is a convenience + method that accepts a model (which should be an instance of ``self.model``\ ) + and unwraps it when necessary to get the plain Python value. + + :param value: A `.BaseModel` instance to convert. + :return: the value, with `.RootModel` unwrapped so it matches the descriptor's + type. + :raises TypeError: if the supplied value cannot be converted to the right type. + """ + if isinstance(value, self.value_type): + return value + elif isinstance(value, RootModel): + root = value.root + if isinstance(root, self.value_type): + return root + msg = f"Model {value} isn't {self.value_type} or a RootModel wrapping it." + raise TypeError(msg) + + @builtins.property + def observable(self) -> bool: # noqa: DOC201 + """Whether this property is observable. + + Observable properties may be observed for changes using the + `.PropertyInfo.observe` method. + + :return: `True` if the property is observable, `False` otherwise. + """ + return isinstance(self.get_descriptor(), DataProperty) + + def observe(self, stream: ObjectSendStream[Message]) -> None: + """Observe changes to this property. + + Changes to this property will be sent to the supplied stream. + + :param stream: The stream to which updated values should be sent. + + :raises NotBoundToInstanceError: if this `.PropertyInfo` is not + bound to a `.Thing` instance. + :raises PropertyNotObservableError: if this property is not observable. + """ + if self.owning_object is None: + msg = "Can't observe property changes from an unbound PropertyInfo." + raise NotBoundToInstanceError(msg) + if not self.observable: + msg = f"Property {self.name} is not observable." + raise PropertyNotObservableError(msg) + self.owning_object._thing_server_interface.subscribe(self.name, stream) + + +class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]): + """Access to metadata on all the properties of a `.Thing` instance or subclass. + + This object may be used as a mapping, to retrieve `.PropertyInfo` objects for + each Property of a `.Thing` by name. This allows easy access to metadata like + their description and model. + """ + + _descriptorinfo_class = PropertyInfo + @overload # use as a decorator @setting def setting( - getter: Callable[[Any], Value], -) -> FunctionalSetting[Value]: ... + getter: Callable[[Owner], Value], +) -> FunctionalSetting[Owner, Value]: ... @overload # use as `field: int = setting(default=0)`` @@ -828,13 +941,13 @@ def setting( def setting( - getter: ValueGetter | EllipsisType = ..., + getter: Callable[[Owner], Value] | EllipsisType = ..., *, default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, **constraints: Any, -) -> FunctionalSetting[Value] | Value: +) -> FunctionalSetting[Owner, Value] | Value: r"""Define a Setting on a `.Thing`\ . A setting is a property that is saved to disk. @@ -920,7 +1033,7 @@ def setting( ) -class BaseSetting(BaseProperty[Value], Generic[Value]): +class BaseSetting(BaseProperty[Owner, Value], Generic[Owner, Value]): r"""A base class for settings. This is a subclass of `.BaseProperty` that is used to define settings. @@ -928,7 +1041,7 @@ class BaseSetting(BaseProperty[Value], Generic[Value]): two concrete implementations: `.DataSetting` and `.FunctionalSetting`\ . """ - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the setting's value without emitting an event. This is used to set the setting's value without notifying observers. @@ -942,8 +1055,19 @@ def set_without_emit(self, obj: Thing, value: Value) -> None: """ raise NotImplementedError("This method should be implemented in subclasses.") + def descriptor_info(self, owner: Owner | None = None) -> SettingInfo[Owner, Value]: + r"""Return an object that allows access to this descriptor's metadata. + + :param owner: An instance to bind the descriptor info to. If `None`\ , + the returned object will be unbound and will only refer to the class. + :return: A `SettingInfo` instance describing this setting. + """ + return SettingInfo(self, owner, self._owner_ref()) + -class DataSetting(DataProperty[Value], BaseSetting[Value], Generic[Value]): +class DataSetting( + DataProperty[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] +): """A `.DataProperty` that persists on disk. A setting can be accessed via the HTTP API and is persistent between sessions. @@ -961,7 +1085,7 @@ class DataSetting(DataProperty[Value], BaseSetting[Value], Generic[Value]): """ def __set__( - self, obj: Thing, value: Value, emit_changed_event: bool = True + self, obj: Owner, value: Value, emit_changed_event: bool = True ) -> None: """Set the setting's value. @@ -974,7 +1098,7 @@ def __set__( super().__set__(obj, value, emit_changed_event) obj.save_settings() - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the property's value, but do not emit event to notify the server. This function is not expected to be used externally. It is called during @@ -987,7 +1111,9 @@ def set_without_emit(self, obj: Thing, value: Value) -> None: super().__set__(obj, value, emit_changed_event=False) -class FunctionalSetting(FunctionalProperty[Value], BaseSetting[Value], Generic[Value]): +class FunctionalSetting( + FunctionalProperty[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] +): """A `.FunctionalProperty` that persists on disk. A setting can be accessed via the HTTP API and is persistent between sessions. @@ -1005,7 +1131,7 @@ class FunctionalSetting(FunctionalProperty[Value], BaseSetting[Value], Generic[V getter and a setter function. """ - def __set__(self, obj: Thing, value: Value) -> None: + def __set__(self, obj: Owner, value: Value) -> None: """Set the setting's value. This will cause the settings to be saved to disk. @@ -1016,7 +1142,7 @@ def __set__(self, obj: Thing, value: Value) -> None: super().__set__(obj, value) obj.save_settings() - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the property's value, but do not emit event to notify the server. This function is not expected to be used externally. It is called during @@ -1029,3 +1155,64 @@ def set_without_emit(self, obj: Thing, value: Value) -> None: # FunctionalProperty does not emit changed events, so no special # behaviour is needed. super().__set__(obj, value) + + +class SettingInfo( + PropertyInfo[BaseSetting[Owner, Value], Owner, Value], Generic[Owner, Value] +): + """Access to the metadata of a setting.""" + + def set_without_emit(self, value: Value) -> None: + """Set the value of the setting, but don't emit a notification. + + :param value: the new value for the setting. + """ + obj = self.owning_object_or_error() + self.get_descriptor().set_without_emit(obj, value) + + def set_without_emit_from_model(self, value: BaseModel) -> None: + """Set the value from a model instance, unwrapping RootModels as needed. + + :param value: the model to extract the value from. + """ + self.set_without_emit(self.model_to_value(value)) + + +class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Owner]): + """Access to metadata on all the properties of a `.Thing` instance or subclass. + + This object may be used as a mapping, to retrieve `.PropertyInfo` objects for + each Property of a `.Thing` by name. This allows easy access to metadata like + their description and model. + """ + + _descriptorinfo_class = SettingInfo + + @builtins.property + def model(self) -> type[BaseModel]: # noqa: DOC201 + """A `pydantic.BaseModel` representing all the settings. + + This `pydantic.BaseModel` is used to load and save the settings to a file. + Note that it uses the ``model`` of each setting, so every field in this model + will be either a `BaseModel` or a `RootModel` instance, unless it is missing. + + Wrapping plain types in a `RootModel` makes no difference to the JSON, but it + means that constraints will be applied and it makes it easier to distinguish + between missing fields and fields that are set to `None`. + """ + name = self.owning_object.name if self.owning_object else self.owning_class.name + fields = {key: (value.model | None, None) for key, value in self.items()} + return create_model( # type: ignore[call-overload] + f"{name}_settings_model", **fields, __config__=ConfigDict(extra="forbid") + ) + + @builtins.property + def model_instance(self) -> BaseModel: # noqa: DOC201 + """An instance of ``self.model`` populated with the current setting values.""" + models = { + # Note that we need to populate it with models, not the bare types. + # This doesn't make a difference to the JSON. + name: setting.model_instance + for name, setting in self.items() + } + return self.model(**models) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index eb7434ff..78d0c95d 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -27,6 +27,7 @@ from ..thing_server_interface import ThingServerInterface from ..thing_description._model import ThingDescription from ..dependencies.thing_server import _thing_servers # noqa: F401 +from ..events import MessageBroker from .config_model import ( ThingsConfig, ThingServerConfig, @@ -92,6 +93,7 @@ def __init__( blob_data_manager.attach_to_app(self.app) self._add_things_view_to_app() self.blocking_portal: Optional[BlockingPortal] = None + self.message_broker = MessageBroker() self.startup_status: dict[str, str | dict] = {"things": {}} global _thing_servers # noqa: F824 _thing_servers.add(self) diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index 02f51ca4..a8600696 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from concurrent.futures import Future from contextlib import contextmanager +from functools import cached_property from typing import ( TYPE_CHECKING, Any, @@ -17,6 +18,8 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock +from labthings_fastapi.events import MessageBroker + from .utilities import class_attributes from .thing_slots import ThingSlot from .thing_server_interface import ThingServerInterface @@ -121,6 +124,11 @@ def _action_manager(self) -> ActionManager: """ raise NotImplementedError("MockThingServerInterface has no ActionManager.") + @cached_property + def _message_broker(self) -> MessageBroker: + """A message broker.""" + return MessageBroker() + ThingSubclass = TypeVar("ThingSubclass", bound="Thing") diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 76981998..4792ae0b 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -11,24 +11,25 @@ from collections.abc import Mapping import logging import os -import json from json.decoder import JSONDecodeError from fastapi.encoders import jsonable_encoder from fastapi import Request, WebSocket -from anyio.abc import ObjectSendStream from anyio.to_thread import run_sync -from pydantic import BaseModel + +from labthings_fastapi.base_descriptor import OptionallyBoundDescriptor from .logs import THING_LOGGER -from .properties import BaseProperty, DataProperty, BaseSetting -from .actions import ActionDescriptor +from .properties import ( + PropertyCollection, + SettingCollection, +) +from .actions import ActionCollection from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes from .thing_description import validation from .utilities.introspection import get_summary, get_docstring from .websockets import websocket_endpoint -from .exceptions import PropertyNotObservableError from .thing_server_interface import ThingServerInterface @@ -175,6 +176,7 @@ def attach_to_server(self, server: ThingServer) -> None: description=get_docstring(self.thing_description), response_model_exclude_none=True, response_model_by_alias=True, + name=f"things.{self.name}", ) def thing_description(request: Request) -> ThingDescription: return self.thing_description(base=str(request.base_url)) @@ -183,28 +185,6 @@ def thing_description(request: Request) -> ThingDescription: async def websocket(ws: WebSocket) -> None: await websocket_endpoint(self, ws) - # A private variable to hold the list of settings so it doesn't need to be - # iterated through each time it is read - _settings_store: Optional[dict[str, BaseSetting]] = None - - @property - def _settings(self) -> dict[str, BaseSetting]: - """A private property that returns a dict of all settings for this Thing. - - Each dict key is the name of the setting, the corresponding value is the - BaseSetting class (a descriptor). This can be used to directly get the - descriptor so that the value can be set without emitting signals, such - as on startup. - """ - if self._settings_store is not None: - return self._settings_store - - self._settings_store = {} - for name, attr in class_attributes(self): - if isinstance(attr, BaseSetting): - self._settings_store[name] = attr - return self._settings_store - def load_settings(self) -> None: """Load settings from json. @@ -220,27 +200,27 @@ def load_settings(self) -> None: """ setting_storage_path = self._thing_server_interface.settings_file_path thing_name = type(self).__name__ - if os.path.exists(setting_storage_path): - self._disable_saving_settings = True - try: - with open(setting_storage_path, "r", encoding="utf-8") as file_obj: - setting_dict = json.load(file_obj) - for key, value in setting_dict.items(): - if key in self._settings: - self._settings[key].set_without_emit(self, value) - else: - _LOGGER.warning( - ( - "Cannot set %s from persistent storage as %s " - "has no matching setting." - ), - key, - thing_name, - ) - except (FileNotFoundError, JSONDecodeError, PermissionError): - _LOGGER.warning("Error loading settings for %s", thing_name) - finally: - self._disable_saving_settings = False + if not os.path.exists(setting_storage_path): + # If the settings file doesn't exist, we have nothing to do - the settings + # are already initialised to their default values. + return + + # Stop recursion by not allowing settings to be saved as we're reading them. + self._disable_saving_settings = True + + try: + with open(setting_storage_path, "r", encoding="utf-8") as file_obj: + settings_model = self.settings.model.model_validate_json( + file_obj.read() + ) + for key, value in settings_model: + if value is None: + continue # `None` means the key was missing + self.settings[key].set_without_emit_from_model(value) + except (FileNotFoundError, JSONDecodeError, PermissionError): + _LOGGER.warning("Error loading settings for %s", thing_name) + finally: + self._disable_saving_settings = False def save_settings(self) -> None: """Save settings to JSON. @@ -250,18 +230,39 @@ def save_settings(self) -> None: """ if self._disable_saving_settings: return - if self._settings is not None: - setting_dict = {} - for name in self._settings.keys(): - value = getattr(self, name) - if isinstance(value, BaseModel): - value = value.model_dump() - setting_dict[name] = value - # Dumpy to string before writing so if this fails the file isn't overwritten - setting_json = json.dumps(setting_dict, indent=4) - path = self._thing_server_interface.settings_file_path - with open(path, "w", encoding="utf-8") as file_obj: - file_obj.write(setting_json) + # We dump to a string first, to avoid corrupting the file if it fails + setting_json = self.settings.model_instance.model_dump_json(indent=4) + path = self._thing_server_interface.settings_file_path + with open(path, "w", encoding="utf-8") as file_obj: + file_obj.write(setting_json) + + properties: OptionallyBoundDescriptor["Thing", PropertyCollection] = ( + OptionallyBoundDescriptor(PropertyCollection) + ) + r"""Access to metadata and functions of this `.Thing`\ 's properties. + + `.Thing.properties` is a mapping of names to `.PropertyInfo` objects, which + allows convenient access to the metadata related to its properties. Note that + this includes settings, as they are a subclass of properties. + """ + + settings: OptionallyBoundDescriptor["Thing", SettingCollection] = ( + OptionallyBoundDescriptor(SettingCollection) + ) + r"""Access to settings-related metadata and functions. + + `.Thing.settings` is a mapping of names to `.SettingInfo` objects that allows + convenient access to metadata of the settings of this `.Thing`\ . + """ + + actions: OptionallyBoundDescriptor["Thing", ActionCollection] = ( + OptionallyBoundDescriptor(ActionCollection) + ) + r"""Access to metadata for the actions of this `.Thing`\ . + + `.Thing.actions` is a mapping of names to `.ActionInfo` objects that allows + convenient access to metadata of each action. + """ _labthings_thing_state: Optional[dict] = None @@ -353,33 +354,3 @@ def thing_description_dict( td: ThingDescription = self.thing_description(path=path, base=base) td_dict: dict = td.model_dump(exclude_none=True, by_alias=True) return jsonable_encoder(td_dict) - - def observe_property(self, property_name: str, stream: ObjectSendStream) -> None: - """Register a stream to receive property change notifications. - - :param property_name: the property to register for. - :param stream: the stream used to send events. - - :raise KeyError: if the requested name is not defined on this Thing. - :raise PropertyNotObservableError: if the property is not observable. - """ - prop = getattr(self.__class__, property_name, None) - if not isinstance(prop, BaseProperty): - raise KeyError(f"{property_name} is not a LabThings Property") - if not isinstance(prop, DataProperty): - raise PropertyNotObservableError(f"{property_name} is not observable.") - prop._observers_set(self).add(stream) - - def observe_action(self, action_name: str, stream: ObjectSendStream) -> None: - """Register a stream to receive action status change notifications. - - :param action_name: the action to register for. - :param stream: the stream used to send events. - - :raise KeyError: if the requested name is not defined on this Thing. - """ - action = getattr(self.__class__, action_name, None) - if not isinstance(action, ActionDescriptor): - raise KeyError(f"{action_name} is not an LabThings Action") - observers = action._observers_set(self) - observers.add(stream) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 4df8f556..1d52b16f 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -14,11 +14,14 @@ ) from weakref import ref, ReferenceType +from anyio.abc import ObjectSendStream + from .exceptions import ServerNotRunningError if TYPE_CHECKING: from .server import ThingServer from .actions import ActionManager + from .events import MessageBroker, Message Params = ParamSpec("Params") @@ -131,6 +134,33 @@ def call_async_task( raise ServerNotRunningError("Can't run async code without an event loop.") return portal.call(async_function, *args) + def publish(self, message: Message) -> None: + """Publish an event. + + Use the async event loop to notify websocket subscribers that something has + happened. + + Note that this function will do nothing if the event loop is not yet running. + + :param affordance: the name of the affordance publishing the event. + :param message: the message being published. + """ + try: + self.start_async_task_soon(self._message_broker.publish, message) + except ServerNotRunningError: + pass # If the server isn't running yet, we can't publish events. + + def subscribe(self, affordance: str, stream: ObjectSendStream[Message]) -> None: + """Subscribe to events from an affordance. + + Use the async event loop to register a stream to receive events + from a particular affordance on this Thing. + + :param affordance: the name of the affordance to subscribe to. + :param stream: the stream to which events should be sent. + """ + self._message_broker.subscribe(self.name, affordance, stream) + @property def settings_folder(self) -> str: """The path to a folder where persistent files may be saved.""" @@ -175,3 +205,11 @@ def _action_manager(self) -> ActionManager: This property may be removed in future, and is for internal use only. """ return self._get_server().action_manager + + @property + def _message_broker(self) -> MessageBroker: + """The message broker attached to the server. + + This property may be removed in the future, and is for internal use. + """ + return self._get_server().message_broker diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 5d953fa5..4a11d0cb 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -59,7 +59,9 @@ def say_hello(self) -> str: ) -class ThingSlot(Generic[ConnectedThings], FieldTypedBaseDescriptor[ConnectedThings]): +class ThingSlot( + Generic[ConnectedThings], FieldTypedBaseDescriptor["Thing", ConnectedThings] +): r"""Descriptor that instructs the server to supply other Things. A `.ThingSlot` provides either one or several @@ -162,16 +164,6 @@ def default(self) -> str | Iterable[str] | None | EllipsisType: """The name of the Thing that will be connected by default, if any.""" return self._default - def __set__(self, obj: "Thing", value: ThingSubclass) -> None: - """Raise an error as this is a read-only descriptor. - - :param obj: the `.Thing` on which the descriptor is defined. - :param value: the value being assigned. - - :raises AttributeError: this descriptor is not writeable. - """ - raise AttributeError("This descriptor is read-only.") - def _pick_things( self, things: "Mapping[str, Thing]", diff --git a/src/labthings_fastapi/websockets.py b/src/labthings_fastapi/websockets.py index a206a4f7..534b1a03 100644 --- a/src/labthings_fastapi/websockets.py +++ b/src/labthings_fastapi/websockets.py @@ -1,21 +1,14 @@ -"""Handle notification of events, property, and action status changes. +r"""Handle notification of events, property, and action status changes via web sockets. -There are several kinds of "event" in the WoT vocabulary, not all of which -are called Event, which is why this module is called `notifications`. -In all cases, these are events that happen on an exposed Thing, and -may need to be relayed to one or more listeners (currently via a -WebSocket connection, though long polling may also be an option in the -future). - -The aim at this stage (July 2023) is for a minimal working example that -enables property changes to be fed via a websocket. Events proper should -not be a big step thereafter. +This module implements a websocket endpoint for a `.Thing`\ . It follows the +subprotocol set out at https://w3c.github.io/web-thing-protocol/ though compliance +with this protocol is not yet verified. The W3C standard does not define a way for one websocket to handle multiple Things, so for now the websocket endpoint will be associated with a single Thing instance. This may change in the future. -(c) Richard Bowman July 2023, released under GNU-LGPL-3.0 +(c) Richard Bowman July 2023, released under MIT license """ from __future__ import annotations @@ -23,9 +16,12 @@ from anyio.abc import ObjectReceiveStream, ObjectSendStream import logging from fastapi import WebSocket, WebSocketDisconnect -from fastapi.encoders import jsonable_encoder from typing import TYPE_CHECKING, Literal + +from labthings_fastapi.middleware.url_for import URLFor from .exceptions import PropertyNotObservableError +from .events import Message +from . import webthing_subprotocol if TYPE_CHECKING: from .thing import Thing @@ -35,8 +31,11 @@ def observation_error_response( - name: str, affordance_type: Literal["action", "property"], exception: Exception -) -> dict[str, str | dict]: + thing: Thing, + affordance: str, + affordance_type: Literal["action", "property"], + exception: Exception, +) -> webthing_subprotocol.ObservationErrorResponse: r"""Generate a websocket error response for observing an action or property. When a websocket client asks to observe a property or action that either @@ -52,31 +51,39 @@ def observation_error_response( or `.PropertyNotObservableError`\ . """ if isinstance(exception, KeyError): - error = { - "status": "404", - "type": f"{WEBTHING_ERROR_URL}#not-found", - "title": "Not Found", - "detail": f"No {affordance_type} found with the name '{name}'.", - } + error = webthing_subprotocol.ProblemDetails( + status=404, + type=f"{WEBTHING_ERROR_URL}#not-found", + title="Not Found", + detail=f"No {affordance_type} found with the name '{affordance}'.", + ) elif isinstance(exception, PropertyNotObservableError): - error = { - "status": "403", - "type": f"{WEBTHING_ERROR_URL}#not-observable", - "title": "Not Observable", - "detail": f"Property '{name}' is not observable.", - } + error = webthing_subprotocol.ProblemDetails( + status=403, + type=f"{WEBTHING_ERROR_URL}#not-observable", + title="Not Observable", + detail=f"Property '{affordance}' is not observable.", + ) else: raise TypeError(f"Can't generate an error response for {exception}.") - return { - "messageType": "response", - "operation": f"observe{affordance_type}", - "name": name, - "error": error, - } + return webthing_subprotocol.ObservationErrorResponse( + thingID=URLFor(f"things.{thing.name}"), + messageType="response", + operation="observeaction" if affordance_type == "action" else "observeproperty", + name=affordance, + error=error, + ) + + +NOTIFICATION_MODELS = { + "property": webthing_subprotocol.ObservePropertyNotification, + "action": webthing_subprotocol.ObserveActionNotification, + "event": webthing_subprotocol.ObserveEventNotification, +} async def relay_notifications_to_websocket( - websocket: WebSocket, receive_stream: ObjectReceiveStream + websocket: WebSocket, receive_stream: ObjectReceiveStream[Message] ) -> None: """Relay objects from a stream to a websocket as JSON. @@ -90,11 +97,11 @@ async def relay_notifications_to_websocket( """ async with receive_stream: async for item in receive_stream: - await websocket.send_json(jsonable_encoder(item)) + await websocket.send_text(item.model_dump_json()) async def process_messages_from_websocket( - websocket: WebSocket, send_stream: ObjectSendStream, thing: Thing + websocket: WebSocket, send_stream: ObjectSendStream[Message], thing: Thing ) -> None: r"""Process messages received from a websocket. @@ -114,20 +121,26 @@ async def process_messages_from_websocket( except WebSocketDisconnect: await send_stream.aclose() return - if data["messageType"] == "addPropertyObservation": + if data["messageType"] == "request" and data["operation"] == "observeproperty": try: - for k in data["data"].keys(): - thing.observe_property(k, send_stream) + k = data.get("name", default="") # The empty string will raise KeyError + thing.properties[k].observe(send_stream) except (KeyError, PropertyNotObservableError) as e: logging.error(f"Got a bad websocket message: {data}, caused {e!r}.") await send_stream.send(observation_error_response(k, "property", e)) - if data["messageType"] == "addActionObservation": + if data["messageType"] == "request" and data["operation"] == "observeaction": try: - for k in data["data"].keys(): - thing.observe_action(k, send_stream) + k = data.get("name", default="") # The empty string will raise KeyError + thing.actions[k].observe(send_stream) except KeyError as e: logging.error(f"Got a bad websocket message: {data}, caused {e!r}.") await send_stream.send(observation_error_response(k, "action", e)) + else: + logging.error(f"Got an unknown websocket message: {data}.") + # Close the stream rather than ignoring bad messages. This will + # cause the websocket client to error out rather than hanging. + await send_stream.aclose() + return async def websocket_endpoint(thing: Thing, websocket: WebSocket) -> None: @@ -141,7 +154,7 @@ async def websocket_endpoint(thing: Thing, websocket: WebSocket) -> None: :param websocket: the web socket that has been created. """ await websocket.accept() - send_stream, receive_stream = create_memory_object_stream[dict]() + send_stream, receive_stream = create_memory_object_stream[Message]() async with create_task_group() as tg: tg.start_soon(relay_notifications_to_websocket, websocket, receive_stream) tg.start_soon(process_messages_from_websocket, websocket, send_stream, thing) diff --git a/src/labthings_fastapi/webthing_subprotocol.py b/src/labthings_fastapi/webthing_subprotocol.py new file mode 100644 index 00000000..9a34d06f --- /dev/null +++ b/src/labthings_fastapi/webthing_subprotocol.py @@ -0,0 +1,93 @@ +"""WebThing WebSocket subprotocol models. + +This module defines models for the messages sent over websockets, which are aligned with +the ``webthingprotocol`` subprotocol as set out in the `community group draft report`_. + +.. _community group draft report: https://w3c.github.io/web-thing-protocol/ + +(c) Richard Bowman July 2023, released under MIT license +""" + +from datetime import datetime +from uuid import uuid4, UUID +from typing import Any, Literal +from pydantic import BaseModel, Field + +from labthings_fastapi.middleware.url_for import URLFor + + +class WebsocketMessage(BaseModel): + """A base model for all websocket messages.""" + + thingID: str | URLFor + messageID: UUID = Field(default_factory=uuid4) + messageType: Literal["request", "response", "notification"] + operation: str + correlationID: UUID | None = None + + +class ObservePropertyMessage(WebsocketMessage): + """A base model for messages related to observing a property.""" + + name: str + operation: Literal["observeproperty"] = "observeproperty" + + +class ObservePropertyNotification(ObservePropertyMessage): + """A model for property change messages.""" + + messageType: Literal["notification"] = "notification" + value: Any + + +class ObserveActionMessage(WebsocketMessage): + """A base model for messages related to observing an action.""" + + name: str + operation: Literal["observeaction"] = "observeaction" + + +class ObserveActionNotification(ObserveActionMessage): + """A model for action notification messages. + + This is not part of the webthingprotocol draft, so should be considered + at risk of summary removal. + """ + + messageType: Literal["notification"] = "notification" + actionID: UUID + state: Literal["pending", "running", "completed", "failed"] + + +class ActionStatus(BaseModel): + """The status of an action invocation.""" + + actionID: UUID + state: Literal["pending", "running", "completed", "failed"] + output: Any | None = None + error: "ProblemDetails | None" = None + timeRequested: datetime | None = None + timeEnded: datetime | None = None + + +class ProblemDetails(BaseModel): + """Details of an error. + + This follows RFC9457_. + + .. _RFC9457: https://datatracker.ietf.org/doc/html/rfc9457 + """ + + status: int + type: str + title: str + detail: str | None = None + + +class ObservationErrorResponse(WebsocketMessage): + """A websocket error response for observing an action or property.""" + + messageType: Literal["response"] = "response" + operation: Literal["observeaction", "observeproperty"] + name: str + error: ProblemDetails diff --git a/tests/test_actions.py b/tests/test_actions.py index d08c6e67..98f56095 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,14 +1,32 @@ import uuid from fastapi.testclient import TestClient +from pydantic import BaseModel import pytest import functools +from labthings_fastapi.actions import ActionInfo from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task, get_link from labthings_fastapi.example_things import MyThing import labthings_fastapi as lt +class ActionMan(lt.Thing): + """A Thing with some actions""" + + _direction: str = "centred" + + @lt.action(response_timeout=0, retention_time=0) + def move_eyes(self, direction: str) -> None: + """Take one input and no outputs""" + self._direction = direction + + @lt.action + def say_hello(self) -> str: + """Return a string.""" + return "Hello World." + + @pytest.fixture def client(): """Yield a client connected to a ThingServer""" @@ -27,6 +45,38 @@ def run(payload=None): return run +def test_action_info(): + """Test the .actions descriptor works as expected.""" + actions = ActionMan.actions + assert len(actions) == 2 + assert set(actions) == {"move_eyes", "say_hello"} + assert actions.is_bound is False + + move_eyes = ActionMan.actions["move_eyes"] + assert isinstance(move_eyes, ActionInfo) + assert move_eyes.name == "move_eyes" + assert move_eyes.description == "Take one input and no outputs" + assert set(move_eyes.input_model.model_fields) == {"direction"} + assert set(move_eyes.output_model.model_fields) == {"root"} # rootmodel for None + assert issubclass(move_eyes.invocation_model, BaseModel) + assert move_eyes.response_timeout == 0 + assert move_eyes.retention_time == 0 + assert move_eyes.is_bound is False + assert callable(move_eyes.func) + + # Try again with a bound one + action_man = create_thing_without_server(ActionMan) + assert len(action_man.actions) == 2 + assert set(action_man.actions) == {"move_eyes", "say_hello"} + assert action_man.actions.is_bound is True + + move_eyes = action_man.actions["move_eyes"] + assert isinstance(move_eyes, ActionInfo) + assert move_eyes.name == "move_eyes" + assert move_eyes.description == "Take one input and no outputs" + assert move_eyes.is_bound is True + + def test_get_action_invocations(client): """Test that running "get" on an action returns a list of invocations.""" # When we start the action has no invocations diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index 4a11b6ed..2cba08a3 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -2,16 +2,27 @@ import pytest from labthings_fastapi.base_descriptor import ( BaseDescriptor, + BaseDescriptorInfo, + DescriptorInfoCollection, FieldTypedBaseDescriptor, DescriptorNotAddedToClassError, DescriptorAddedToClassTwiceError, + FieldTypedBaseDescriptorInfo, + OptionallyBoundDescriptor, + OptionallyBoundInfo, get_class_attribute_docstrings, ) +from labthings_fastapi.testing import create_thing_without_server from .utilities import raises_or_is_caused_by -from labthings_fastapi.exceptions import MissingTypeError, InconsistentTypeError +from labthings_fastapi.exceptions import ( + MissingTypeError, + InconsistentTypeError, + NotBoundToInstanceError, +) +import labthings_fastapi as lt -class MockProperty(BaseDescriptor[str]): +class MockProperty(BaseDescriptor[lt.Thing, str]): """A mock property class.""" # The line below isn't defined on a `Thing`, so mypy @@ -299,10 +310,10 @@ class FieldTypedExample: """An example with field-typed descriptors.""" int_or_str_prop: int | str = FieldTypedBaseDescriptor() - int_or_str_subscript = FieldTypedBaseDescriptor[int | str]() + int_or_str_subscript = FieldTypedBaseDescriptor[lt.Thing, int | str]() int_or_str_stringified: "int | str" = FieldTypedBaseDescriptor() customprop: CustomType = FieldTypedBaseDescriptor() - customprop_subscript = FieldTypedBaseDescriptor[CustomType]() + customprop_subscript = FieldTypedBaseDescriptor[lt.Thing, CustomType]() futureprop: "FutureType" = FieldTypedBaseDescriptor() @@ -408,7 +419,7 @@ class Example3: with raises_or_is_caused_by(MissingTypeError) as excinfo: class Example4: - field6 = FieldTypedBaseDescriptor["str"]() + field6 = FieldTypedBaseDescriptor[lt.Thing, "str"]() msg = str(excinfo.value) assert "forward reference" in msg @@ -421,7 +432,7 @@ def test_mismatched_types(): with raises_or_is_caused_by(InconsistentTypeError): class Example3: - field: int = FieldTypedBaseDescriptor[str]() + field: int = FieldTypedBaseDescriptor[lt.Thing, str]() def test_double_specified_types(): @@ -432,7 +443,7 @@ def test_double_specified_types(): """ class Example4: - field: int | None = FieldTypedBaseDescriptor[int | None]() + field: int | None = FieldTypedBaseDescriptor[lt.Thing, int | None]() assert Example4.field.value_type == int | None @@ -448,4 +459,182 @@ def test_stringified_vs_unstringified_mismatch(): with raises_or_is_caused_by(InconsistentTypeError): class Example5: - field: "int" = FieldTypedBaseDescriptor[int]() + field: "int" = FieldTypedBaseDescriptor[lt.Thing, int]() + + +def test_optionally_bound_info(): + """Test the OptionallyBoundInfo base class.""" + + class Example6(lt.Thing): + pass + + class Example6a(lt.Thing): + pass + + example6 = create_thing_without_server(Example6) + + bound_info = OptionallyBoundInfo(example6) + assert bound_info.owning_object is example6 + assert bound_info.owning_object_or_error() is example6 + assert bound_info.owning_class is Example6 + assert bound_info.is_bound is True + + unbound_info = OptionallyBoundInfo(None, Example6) + assert unbound_info.owning_object is None + with pytest.raises(NotBoundToInstanceError): + unbound_info.owning_object_or_error() + assert unbound_info.owning_class is Example6 + assert unbound_info.is_bound is False + + # Check that we can't create it with a bad class + with pytest.raises(TypeError): + _ = OptionallyBoundInfo(example6, Example6a) + + # Check that we can't create it with no class or object + with pytest.raises(ValueError): + _ = OptionallyBoundInfo(None, None) # type: ignore + + +def test_descriptorinfo(): + """Test that the DescriptorInfo object works as expected.""" + + class Example7: + intfield: int = FieldTypedBaseDescriptor() + """The descriptor's title. + + A description from a multiline docstring. + """ + + strprop = BaseDescriptor["Example7", str]() + + intfield_descriptor = Example7.intfield + assert isinstance(intfield_descriptor, FieldTypedBaseDescriptor) + + # First, make an unbound info object + intfield_info = intfield_descriptor.descriptor_info() + assert intfield_info.is_bound is False + assert intfield_info.name == "intfield" + assert intfield_info.title == "The descriptor's title." + assert intfield_info.description == "A description from a multiline docstring." + with pytest.raises(NotBoundToInstanceError): + intfield_info.get() + + # Next, check the bound version + example6 = Example7() + intfield_info = intfield_descriptor.descriptor_info(example6) + assert intfield_info.is_bound is True + assert intfield_info.name == "intfield" + assert intfield_info.title == "The descriptor's title." + assert intfield_info.description == "A description from a multiline docstring." + with pytest.raises(NotImplementedError): + # As we're now calling on a bound info object, we should just get the + # exception from `BaseDescriptor.instance_get()`, not the unbound error. + intfield_info.get() + with pytest.raises(AttributeError, match="read-only"): + # As we're now calling on a bound info object, we should just get the + # exception from `BaseDescriptor.__set__(value)`, not the unbound error. + intfield_info.set(10) + assert intfield_info.value_type is int + + # Check strprop, which is missing most of the documentation properties and + # should not have a value_type. + strprop_descriptor = Example7.strprop + assert isinstance(strprop_descriptor, BaseDescriptor) + strprop_info = strprop_descriptor.descriptor_info() + assert strprop_info.name == "strprop" + assert strprop_info.title.lower() == "strprop" + assert strprop_info.description is None + with pytest.raises(AttributeError): + _ = strprop_info.value_type + + +def test_descriptorinfocollection(): + """Test the DescriptorInfoCollection class. + + This test checks that: + * We can get a collection of all descriptors on a Thing subclass. + * The collection contains the right names (is filtered by type). + * The individual DescriptorInfo objects in the collection have the + right properties. + * The `OptionallyBoundDescriptor` returns a collection on either the + class or the instance, bound or unbound as appropriate. + """ + + class BaseDescriptorInfoCollection( + DescriptorInfoCollection[lt.Thing, BaseDescriptorInfo] + ): + """A collection of BaseDescriptorInfo objects.""" + + _descriptorinfo_class = BaseDescriptorInfo + + class FieldTypedBaseDescriptorInfoCollection( + DescriptorInfoCollection[lt.Thing, FieldTypedBaseDescriptorInfo] + ): + """A collection of FieldTypedBaseDescriptorInfo objects.""" + + _descriptorinfo_class = FieldTypedBaseDescriptorInfo + + class Example8(lt.Thing): + intfield: int = FieldTypedBaseDescriptor() + """An integer field.""" + + strprop = BaseDescriptor["Example8", str]() + """A string property.""" + + another_intfield: int = FieldTypedBaseDescriptor() + """Another integer field.""" + + base_descriptors = OptionallyBoundDescriptor(BaseDescriptorInfoCollection) + """A mapping of all base descriptors.""" + + field_typed_descriptors = OptionallyBoundDescriptor( + FieldTypedBaseDescriptorInfoCollection + ) + """A mapping of all field-typed descriptors.""" + + # The property should return a mapping of names to descriptor info objects + collection = Example8.base_descriptors + assert isinstance(collection, DescriptorInfoCollection) + + names = list(collection) + assert set(names) == {"intfield", "strprop", "another_intfield"} + assert len(collection) == 3 + assert collection.is_bound is False + + intfield_info = collection["intfield"] + assert isinstance(intfield_info, FieldTypedBaseDescriptorInfo) + assert intfield_info.name == "intfield" + assert intfield_info.title == "An integer field." + assert intfield_info.value_type is int + assert intfield_info.is_bound is False + + strprop_info = collection["strprop"] + assert strprop_info.name == "strprop" + assert strprop_info.title == "A string property." + with pytest.raises(AttributeError): + _ = strprop_info.value_type # type: ignore + assert strprop_info.is_bound is False + + # A more specific descriptor info type should narrow the collection + field_typed_collection = Example8.field_typed_descriptors + assert isinstance(field_typed_collection, DescriptorInfoCollection) + names = list(field_typed_collection) + assert set(names) == {"intfield", "another_intfield"} + assert len(field_typed_collection) == 2 + + assert field_typed_collection["intfield"] is intfield_info + assert field_typed_collection["another_intfield"] is collection["another_intfield"] + + example8 = create_thing_without_server(Example8) + bound_collection = example8.base_descriptors + assert bound_collection.is_bound is True + bound_names = list(bound_collection) + assert set(bound_names) == {"intfield", "strprop", "another_intfield"} + assert len(bound_collection) == 3 + + bound_intfield_info = bound_collection["intfield"] + assert bound_intfield_info.is_bound is True + + assert "spurious_name" not in collection + assert "spurious_name" not in bound_collection + assert "spurious_name" not in field_typed_collection diff --git a/tests/test_properties.py b/tests/test_properties.py index 4bf27bfd..0892d6a5 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -10,10 +10,12 @@ import labthings_fastapi as lt from labthings_fastapi.exceptions import ( + NotBoundToInstanceError, ServerNotRunningError, UnsupportedConstraintError, ) -from labthings_fastapi.properties import BaseProperty +from labthings_fastapi.properties import BaseProperty, PropertyInfo +from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task @@ -26,10 +28,10 @@ def __init__(self, **kwargs): self._constrained_functional_str_setting = "ddd" boolprop: bool = lt.property(default=False) - "A boolean property" + "A boolean property." stringprop: str = lt.property(default="foo") - "A string property" + "A string property." _undoc = None @@ -64,18 +66,18 @@ def toggle_boolprop_from_thread(self): t.join() constrained_int: int = lt.property(default=5, ge=0, le=10, multiple_of=2) - "An integer property with constraints" + "An integer property with constraints." constrained_float: float = lt.property(default=5, gt=0, lt=10, allow_inf_nan=False) - "A float property with constraints" + "A float property with constraints." constrained_str: str = lt.property( default="hello", min_length=3, max_length=10, pattern="^[a-z]+$" ) - "A string property with constraints" + "A string property with constraints." constrained_int_setting: int = lt.setting(default=5, ge=0, le=10, multiple_of=2) - "An integer setting with constraints" + "An integer setting with constraints." @lt.property def constrained_functional_int(self) -> int: @@ -453,3 +455,34 @@ class AnotherBadConstraintThing(lt.Thing): # Some inappropriate constraints (e.g. multiple_of on str) are passed through # as metadata if used on the wrong type. We don't currently raise errors # for these. + + +def test_propertyinfo(): + """Check the PropertyInfo class is generated correctly.""" + + # Create a PropertyInfo object and check it matches the property + info = PropertyTestThing.properties["stringprop"] + assert isinstance(info, PropertyInfo) + assert info.name == "stringprop" + assert info.description == "A string property." + assert info.value_type is str + assert issubclass(info.model, RootModel) + assert info.model.model_fields["root"].annotation is str + assert info.is_bound is False + with pytest.raises(NotBoundToInstanceError): + info.get() + + # Try the same thing for an instance + thing = create_thing_without_server(PropertyTestThing) + binfo = thing.properties["stringprop"] + assert isinstance(binfo, PropertyInfo) + assert binfo.name == "stringprop" + assert binfo.description == "A string property." + assert binfo.value_type is str + assert issubclass(binfo.model, RootModel) + assert binfo.model.model_fields["root"].annotation is str + assert binfo.is_bound is True + assert binfo.get() == "foo" + + assert "not a property" not in PropertyTestThing.properties + assert "not a property" not in thing.properties diff --git a/tests/test_property.py b/tests/test_property.py index 2ba34417..1d9fbc96 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -179,6 +179,12 @@ class Example: assert str(Example.prop.value_type) == "str | None" assert issubclass(Example.prop.model, pydantic.RootModel) assert str(Example.prop.model.model_fields["root"].annotation) == "str | None" + testmodel = Example.prop.value_to_model("test") + assert isinstance(testmodel, pydantic.RootModel) + assert testmodel.root == "test" + nonemodel = Example.prop.value_to_model(None) + assert isinstance(nonemodel, pydantic.RootModel) + assert nonemodel.root is None def test_baseproperty_type_and_model_pydantic(): @@ -198,6 +204,10 @@ class Example: assert Example.prop.value_type is MyModel assert Example.prop.model is MyModel + value = MyModel(foo="test", bar=42) + assert isinstance(value, Example.prop.value_type) + assert Example.prop.value_to_model(value) is value + def test_baseproperty_add_to_fastapi(): """Check the method that adds the property to the HTTP API.""" diff --git a/tests/test_settings.py b/tests/test_settings.py index 19357748..359a1e8e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,14 +4,26 @@ from typing import Any import pytest import os -import logging +from pydantic import BaseModel, ValidationError from fastapi.testclient import TestClient import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server +class MyModel(BaseModel): + """A basic Model subclass. + + This is used to test that we can safely load/save settings that are + `.BaseModel` instances. Prior to v0.0.14, these were loaded as dictionaries + but they should now be correctly reinflated to the right class. + """ + + a: int + b: str + + class ThingWithSettings(lt.Thing): """A test `.Thing` with some settings and actions.""" @@ -22,13 +34,16 @@ def __init__(self, **kwargs: Any) -> None: self._localonlysetting = "Local-only default." boolsetting: bool = lt.setting(default=False) - "A boolean setting" + "A boolean setting." stringsetting: str = lt.setting(default="foo") - "A string setting" + "A string setting." dictsetting: dict = lt.setting(default_factory=lambda: {"a": 1, "b": 2}) - "A dictionary setting" + "A dictionary setting." + + modelsetting: MyModel = lt.setting(default_factory=lambda: MyModel(a=0, b="string")) + "A setting that is a BaseModel." @lt.setting def floatsetting(self) -> float: @@ -98,6 +113,7 @@ def _settings_dict( floatsetting=1.0, stringsetting="foo", dictsetting=None, + modelsetting=None, localonlysetting="Local-only default.", localonly_boolsetting=False, ): @@ -107,11 +123,14 @@ def _settings_dict( """ if dictsetting is None: dictsetting = {"a": 1, "b": 2} + if modelsetting is None: + modelsetting = {"a": 0, "b": "string"} return { "boolsetting": boolsetting, "floatsetting": floatsetting, "stringsetting": stringsetting, "dictsetting": dictsetting, + "modelsetting": modelsetting, "localonlysetting": localonlysetting, "localonly_boolsetting": localonly_boolsetting, } @@ -134,13 +153,15 @@ def test_setting_available(): assert thing.floatsetting == 1.0 assert thing.localonlysetting == "Local-only default." assert thing.dictsetting == {"a": 1, "b": 2} + assert thing.modelsetting == MyModel(a=0, b="string") def test_functional_settings_save(tempdir): """Check updated settings are saved to disk ``floatsetting`` is a functional setting, we should also test - a `.DataSetting` for completeness.""" + a `.DataSetting` for completeness. + """ server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) setting_file = _get_setting_file(server, "thing") # No setting file created when first added @@ -151,12 +172,12 @@ def test_functional_settings_save(tempdir): # A 201 return code means the operation succeeded (i.e. # the property was written to) assert r.status_code == 201 - # We check the value with a GET request - r = client.get("/thing/floatsetting") - assert r.json() == 2.0 # After successfully writing to the setting, it should # have created a settings file. assert os.path.isfile(setting_file) + # We check the value with a GET request + r = client.get("/thing/floatsetting") + assert r.json() == 2.0 with open(setting_file, "r", encoding="utf-8") as file_obj: # Check settings on file match expected dictionary assert json.load(file_obj) == _settings_dict(floatsetting=2.0) @@ -253,21 +274,9 @@ def test_load_extra_settings(caplog, tempdir): with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) - with caplog.at_level(logging.WARNING): + with pytest.raises(ValidationError, match="extra_forbidden"): # Create the server with the Thing added. - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert caplog.records[0].name == "labthings_fastapi.thing" - - # Get the instance of the ThingWithSettings - thing = server.things["thing"] - assert isinstance(thing, ThingWithSettings) - - # Check other settings are loaded as expected - assert not thing.boolsetting - assert thing.stringsetting == "bar" - assert thing.floatsetting == 3.0 + _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) def test_try_loading_corrupt_settings(tempdir, caplog): @@ -286,19 +295,7 @@ def test_try_loading_corrupt_settings(tempdir, caplog): with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) - # Recreate the server and check for warnings - with caplog.at_level(logging.WARNING): + # Recreate the server and check for the error + with pytest.raises(ValidationError, match="Invalid JSON"): # Add thing to server - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert caplog.records[0].name == "labthings_fastapi.thing" - - # Get the instance of the ThingWithSettings - thing = server.things["thing"] - assert isinstance(thing, ThingWithSettings) - - # Check default settings are loaded - assert not thing.boolsetting - assert thing.stringsetting == "foo" - assert thing.floatsetting == 1.0 + _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 2781e04f..d56f015b 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,4 +1,6 @@ +from anyio import create_memory_object_stream from fastapi.testclient import TestClient +from pydantic import BaseModel import pytest import labthings_fastapi as lt from labthings_fastapi.exceptions import ( @@ -86,17 +88,18 @@ def thing(): return create_thing_without_server(ThingWithProperties) -def test_observing_dataprop(thing, mocker): +def test_observing_dataprop(thing): """Check `observe_property` is OK on a data property. This checks that something is added to the set of observers. We don't check for events, as there's no event loop: this is tested in `test_observing_dataprop_with_ws` below. """ - observers_set = ThingWithProperties.dataprop._observers_set(thing) - fake_observer = mocker.Mock() - thing.observe_property("dataprop", fake_observer) - assert fake_observer in observers_set + send_stream, receive_stream = create_memory_object_stream[BaseModel]() + thing.properties["dataprop"].observe(send_stream) + event_broker = thing._thing_server_interface._event_broker + observers_set = event_broker._subscriptions[thing.name]["dataprop"] + assert send_stream in observers_set @pytest.mark.parametrize( @@ -113,7 +116,7 @@ def test_observing_dataprop(thing, mocker): def test_observing_errors(thing, mocker, name, exception): """Check errors are raised if we observe an unsuitable property.""" with pytest.raises(exception): - thing.observe_property(name, mocker.Mock()) + thing.properties[name].observe(mocker.Mock()) def test_observing_dataprop_with_ws(client, ws): @@ -125,8 +128,9 @@ def test_observing_dataprop_with_ws(client, ws): # Observe the property. ws.send_json( { - "messageType": "addPropertyObservation", - "data": {"dataprop": True}, + "messageType": "request", + "operation": "observeproperty", + "name": "dataprop", } ) for val in [1, 10, 0]: @@ -134,24 +138,28 @@ def test_observing_dataprop_with_ws(client, ws): client.put("/thing/dataprop", json=val) # Receive the message and check it's as expected. message = ws.receive_json(mode="text") - assert message["messageType"] == "propertyStatus" - assert message["data"]["dataprop"] == val + assert message["messageType"] == "notification" + assert message["operation"] == "observeproperty" + assert message["name"] == "dataprop" + assert message["value"] == val # Increment the value with an action client.post("/thing/increment_dataprop") message = ws.receive_json(mode="text") - assert message["messageType"] == "propertyStatus" - assert message["data"]["dataprop"] == 1 + assert message["messageType"] == "notification" + assert message["operation"] == "observeproperty" + assert message["name"] == "dataprop" + assert message["value"] == 1 @pytest.mark.parametrize( argnames=["name", "title", "status"], argvalues=[ - ("funcprop", "Not Observable", "403"), - ("non_property", "Not Found", "404"), - ("python_property", "Not Found", "404"), - ("undecorated", "Not Found", "404"), - ("increment_dataprop", "Not Found", "404"), - ("missing", "Not Found", "404"), + ("funcprop", "Not Observable", 403), + ("non_property", "Not Found", 404), + ("python_property", "Not Found", 404), + ("undecorated", "Not Found", 404), + ("increment_dataprop", "Not Found", 404), + ("missing", "Not Found", 404), ], ) def test_observing_dataprop_error_with_ws(ws, name, title, status): @@ -162,8 +170,9 @@ def test_observing_dataprop_error_with_ws(ws, name, title, status): # Observe the property. ws.send_json( { - "messageType": "addPropertyObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeproperty", + "name": name, } ) # Receive the message and check for the error. @@ -178,9 +187,10 @@ def test_observing_action(thing, mocker): This verifies we've added an observer to the set, but doesn't test for notifications: that would require an event loop. """ - observers_set = ThingWithProperties.increment_dataprop._observers_set(thing) fake_observer = mocker.Mock() - thing.observe_action("increment_dataprop", fake_observer) + thing.actions["increment_dataprop"].observe(fake_observer) + event_broker = thing._thing_server_interface._event_broker + observers_set = event_broker._subscriptions[thing.name]["increment_dataprop"] assert fake_observer in observers_set @@ -190,7 +200,7 @@ def test_observing_action(thing, mocker): def test_observing_action_error(thing, mocker, name): """Check observing an attribute that's not an action raises an error.""" with pytest.raises(KeyError): - thing.observe_action(name, mocker.Mock()) + thing.actions[name].observe(mocker.Mock()) @pytest.mark.parametrize( @@ -206,8 +216,9 @@ def test_observing_action_with_ws(client, ws, name, final_status): # Observe the property. ws.send_json( { - "messageType": "addActionObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeaction", + "name": name, } ) # Invoke the action (via HTTP) @@ -215,8 +226,10 @@ def test_observing_action_with_ws(client, ws, name, final_status): # We should see the status go through the expected sequence for expected_status in ["pending", "running", final_status]: message = ws.receive_json(mode="text") - assert message["messageType"] == "actionStatus" - assert message["data"]["status"] == expected_status + assert message["messageType"] == "notification" + assert message["operation"] == "observeaction" + assert message["name"] == name + assert message["status"] == expected_status @pytest.mark.parametrize( @@ -230,11 +243,12 @@ def test_observing_action_error_with_ws(ws, name): # Observe the property. ws.send_json( { - "messageType": "addActionObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeaction", + "name": name, } ) # Receive the message and check for the error. message = ws.receive_json(mode="text") assert message["error"]["title"] == "Not Found" - assert message["error"]["status"] == "404" + assert message["error"]["status"] == 404 diff --git a/typing_tests/thing_properties.py b/typing_tests/thing_properties.py index ee9a421a..0a5962ad 100644 --- a/typing_tests/thing_properties.py +++ b/typing_tests/thing_properties.py @@ -66,7 +66,7 @@ def strprop(self: typing.Any) -> str: return "foo" -assert_type(strprop, FunctionalProperty[str]) +assert_type(strprop, FunctionalProperty[typing.Any, str]) class TestPropertyDefaultsMatch(lt.Thing): @@ -138,34 +138,38 @@ class TestExplicitDescriptor(lt.Thing): the underlying class as well. """ - intprop1 = lt.DataProperty[int](default=0) + intprop1 = lt.DataProperty["TestExplicitDescriptor", int](default=0) """A DataProperty that should not cause mypy errors.""" - intprop2 = lt.DataProperty[int](default_factory=int_factory) + intprop2 = lt.DataProperty["TestExplicitDescriptor", int]( + default_factory=int_factory + ) """The factory matches the type hint, so this should be OK.""" - intprop3 = lt.DataProperty[int](default_factory=optional_int_factory) - """Uses a factory function that doesn't match the type hint. - - This ought to cause mypy to throw an error, as the factory function can - return None, but at time of writing this doesn't happen. - - This error is caught correctly when called via `lt.property`. - """ + intprop3 = lt.DataProperty["TestExplicitDescriptor", int]( + default_factory=optional_int_factory, # type: ignore[arg-type] + ) + """Uses a factory function that doesn't match the type hint.""" - intprop4 = lt.DataProperty[int](default="foo") # type: ignore[call-overload] + intprop4 = lt.DataProperty["TestExplicitDescriptor", int](default="foo") # type: ignore[call-overload] """This property should cause an error, as the default is a string.""" - intprop5 = lt.DataProperty[int]() # type: ignore[call-overload] + intprop5 = lt.DataProperty["TestExplicitDescriptor", int]() # type: ignore[call-overload] """This property should cause mypy to throw an error, as it has no default.""" - optionalintprop1 = lt.DataProperty[int | None](default=None) + optionalintprop1 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default=None + ) """A DataProperty that should not cause mypy errors.""" - optionalintprop2 = lt.DataProperty[int | None](default_factory=optional_int_factory) + optionalintprop2 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default_factory=optional_int_factory + ) """This property should not cause mypy errors: the factory matches the type hint.""" - optionalintprop3 = lt.DataProperty[int | None](default_factory=int_factory) + optionalintprop3 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default_factory=int_factory + ) """Uses a factory function that is a subset of the type hint.""" @@ -181,19 +185,21 @@ class TestExplicitDescriptor(lt.Thing): assert_type(test_explicit_descriptor.optionalintprop3, int | None) # Check class attributes are typed correctly. -assert_type(TestExplicitDescriptor.intprop1, lt.DataProperty[int]) -assert_type(TestExplicitDescriptor.intprop2, lt.DataProperty[int]) -assert_type(TestExplicitDescriptor.intprop3, lt.DataProperty[int]) +cls: typing.TypeAlias = TestExplicitDescriptor +assert_type(cls.intprop1, lt.DataProperty[cls, int]) +assert_type(cls.intprop2, lt.DataProperty[cls, int]) +assert_type(cls.intprop3, lt.DataProperty[cls, int]) -assert_type(TestExplicitDescriptor.optionalintprop1, lt.DataProperty[int | None]) -assert_type(TestExplicitDescriptor.optionalintprop2, lt.DataProperty[int | None]) -assert_type(TestExplicitDescriptor.optionalintprop3, lt.DataProperty[int | None]) +assert_type(cls.optionalintprop1, lt.DataProperty[cls, int | None]) +assert_type(cls.optionalintprop2, lt.DataProperty[cls, int | None]) +assert_type(cls.optionalintprop3, lt.DataProperty[cls, int | None]) +Owner = typing.TypeVar("Owner", bound=lt.Thing) Val = typing.TypeVar("Val") -def f_property(getter: typing.Callable[..., Val]) -> FunctionalProperty[Val]: +def f_property(getter: typing.Callable[[Owner], Val]) -> FunctionalProperty[Owner, Val]: """A function that returns a FunctionalProperty with a getter.""" return FunctionalProperty(getter) @@ -231,7 +237,7 @@ def intprop3(self) -> int: """This getter is fine, but the setter should fail type checking.""" return 0 - @intprop3.setter + @intprop3.setter # type: ignore[arg-type] def _set_intprop3(self, value: str) -> None: """Setter for intprop3. It's got the wrong type so should fail.""" pass @@ -267,15 +273,16 @@ def strprop(self, val: str) -> None: pass -assert_type(TestFunctionalProperty.intprop1, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.intprop2, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.intprop3, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.fprop, FunctionalProperty[int]) +cls1: typing.TypeAlias = TestFunctionalProperty +assert_type(cls1.intprop1, FunctionalProperty[cls1, int]) +assert_type(cls1.intprop2, FunctionalProperty[cls1, int]) +assert_type(cls1.intprop3, FunctionalProperty[cls1, int]) +assert_type(cls1.fprop, FunctionalProperty[cls1, int]) # Don't check ``strprop`` because it caused an error and thus will # not have the right type, even though the error is ignored. -test_functional_property = create_thing_without_server(TestFunctionalProperty) -assert_type(test_functional_property, TestFunctionalProperty) +test_functional_property = create_thing_without_server(cls1) +assert_type(test_functional_property, cls1) assert_type(test_functional_property.intprop1, int) assert_type(test_functional_property.intprop2, int) assert_type(test_functional_property.intprop3, int)