diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index f75325e7..be4dfb44 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -39,7 +39,11 @@ from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks from pydantic import BaseModel, create_model -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 @@ -622,6 +626,54 @@ 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 + + +class ActionCollection( + DescriptorInfoCollection[OwnerT, ActionInfo], + Generic[OwnerT], +): + """Access to the metadata of each Action.""" + + _descriptorinfo_class = ActionInfo + + class ActionDescriptor( BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]], Generic[ActionParams, ActionReturn, OwnerT], @@ -914,6 +966,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 f229c1d2..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 @@ -32,6 +51,15 @@ 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. @@ -144,6 +172,186 @@ def _set_prop4(self, val): """ +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. @@ -219,6 +427,7 @@ def __set_name__(self, owner: type[Owner], 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`). @@ -362,6 +571,82 @@ def instance_get(self, obj: Owner) -> 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[Owner, Value], BaseDescriptor[Owner, Value]): """A BaseDescriptor that determines its type like a dataclass field.""" @@ -380,10 +665,6 @@ 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[Owner], name: str) -> None: r"""Take note of the name and type. @@ -460,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 @@ -491,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 " @@ -532,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/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 c948c9d4..bd02e770 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -62,7 +62,7 @@ class attribute. Documentation is in strings immediately following the from weakref import WeakSet from fastapi import Body, FastAPI -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, ConfigDict, RootModel, create_model from .thing_description import type_to_dataschema from .thing_description._model import ( @@ -73,7 +73,11 @@ class attribute. Documentation is in strings immediately following the ) from .utilities import labthings_data, wrap_plain_types_in_rootmodel from .utilities.introspection import return_type -from .base_descriptor import FieldTypedBaseDescriptor +from .base_descriptor import ( + DescriptorInfoCollection, + FieldTypedBaseDescriptor, + FieldTypedBaseDescriptorInfo, +) from .exceptions import ( NotConnectedToServerError, ReadOnlyPropertyError, @@ -137,6 +141,9 @@ class MissingDefaultError(ValueError): 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 = ..., @@ -488,6 +495,17 @@ def __set__(self, obj: Owner, 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[Owner, Value], Generic[Owner, Value]): """A Property descriptor that acts like a regular variable. @@ -799,6 +817,82 @@ def __set__(self, obj: Owner, value: Value) -> None: self.fset(obj, value) +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. + """ + value = self.get() + 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) + + 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) + + +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[[Owner], Value], @@ -930,6 +1024,15 @@ def set_without_emit(self, obj: Owner, 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[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] @@ -1021,3 +1124,64 @@ def set_without_emit(self, obj: Owner, 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/thing.py b/src/labthings_fastapi/thing.py index 76981998..86b1f6f5 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -11,18 +11,23 @@ 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 ( + BaseProperty, + DataProperty, + PropertyCollection, + SettingCollection, +) +from .actions import ActionCollection, ActionDescriptor from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes from .thing_description import validation @@ -183,28 +188,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 +203,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 +233,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 diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 5c5ea745..4a11d0cb 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -164,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/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 ed69985e..2cba08a3 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -2,13 +2,23 @@ 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 @@ -450,3 +460,181 @@ def test_stringified_vs_unstringified_mismatch(): class Example5: 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_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)