Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,14 @@ class NotBoundToInstanceError(RuntimeError):
generated from a `.Thing` class. Usually, they should be accessed via a
`.Thing` instance, in which case they will be bound.
"""


class FeatureNotAvailable(NotImplementedError):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is generally recommended for all exceptions to end with Error. On the OFM there is a Ruff rule that enforces this.

"""A feature is not available.

There are some methods provided by base classes where implementation is optional.
These methods raise `FeatureNotAvailable` if they are not implemented.

Currently this is done for the default value of properties, and their reset
method.
"""
121 changes: 120 additions & 1 deletion src/labthings_fastapi/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
FieldTypedBaseDescriptorInfo,
)
from .exceptions import (
FeatureNotAvailable,
NotConnectedToServerError,
ReadOnlyPropertyError,
MissingTypeError,
Expand Down Expand Up @@ -393,6 +394,50 @@
)
return self._model

def default(self, obj: Owner | None) -> Value:
"""Return the default value of this property.

:param obj: the `.Thing` instance on which we are looking for the default.
or `None` if referring to the class. For now, this is ignored.

:return: the default value of this property.
:raises FeatureNotAvailable: as this must be overridden.
"""
raise FeatureNotAvailable(
f"{obj.name if obj else self.__class__}.{self.name} cannot be reset, "
f"as it's not supported by {self.__class__}."
)

def reset(self, obj: Owner) -> None:
"""Reset the property's value to a default state.

If there is a defined default value for the property, this method
should reset the property to that default.

Not every property is expected to implement ``reset`` so it is important
to handle `.FeatureNotAvailable` exceptions, which will be raised if this
method is not overridden.

:param obj: the `.Thing` instance we want to reset.
:raises FeatureNotAvailable: as only some subclasses implement resetting.
"""
raise FeatureNotAvailable(
f"{obj.name}.{self.name} cannot be reset, as it's not supported by "
f"{self.__class__}."
)

def is_resettable(self, obj: Owner | None) -> bool:
r"""Determine if it's possible to reset this property.

By default, this returns `True` if ``reset`` has been overridden.
If you override ``reset`` but want more control over this behaviour,
you probably need to override `is_resettable`\ .

:param obj: the `.Thing` instance we want to reset.
:return: `True` if a call to ``reset()`` should work.
"""
return BaseProperty.reset is not self.__class__.reset

def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None:
"""Add this action to a FastAPI app, bound to a particular Thing.

Expand Down Expand Up @@ -439,8 +484,27 @@
def get_property() -> Any:
return self.__get__(thing)

if self.is_resettable(thing):

@app.post(
thing.path + self.name + "/reset",
summary=f"Reset {self.title}.",
description=(
f"## Reset {self.title}\n\n"
"This endpoint will reset the property to its default value. "
"The default value should be detailed in the Thing Description.\n\n"
"Not every property supports the reset-to-default operation, and "
"this endpoint is only present (e.g. in the OpenAPI docs) "
"for those that do.\n\n"
"This endpoint is identical to using the ``reset_property`` action"
rf"with the ``name`` argument set to ``{self.name}``\ ."
),
)
def reset() -> None:
self.reset(thing)

Check warning on line 504 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

504 line is not covered with tests

def property_affordance(
self, thing: Thing, path: str | None = None
self, thing: Owner, path: str | None = None
) -> PropertyAffordance:
"""Represent the property in a Thing Description.

Expand All @@ -467,12 +531,22 @@
),
]
data_schema: DataSchema = type_to_dataschema(self.model)
extra_fields = {}
try:
# Try to get hold of the default - may raise FeatureNotAvailable
default = self.default(thing)
# Validate and dump it with the model to ensure it's simple types only
default_validated = self.model.model_validate(default)
extra_fields["default"] = default_validated.model_dump()
except FeatureNotAvailable:
pass # Default should only be included if it's needed.
pa: PropertyAffordance = PropertyAffordance(
title=self.title,
forms=forms,
description=self.description,
readOnly=self.readonly,
writeOnly=False, # write-only properties are not yet supported
**extra_fields,
)
# We merge the data schema with the property affordance (which subclasses the
# DataSchema model) with the affordance second so its values take priority.
Expand Down Expand Up @@ -612,6 +686,27 @@
if emit_changed_event:
self.emit_changed_event(obj, value)

def default(self, obj: Owner | None) -> Value:
"""Return the default value of this property.

Note that this implementation is independent of the `.Thing` instance,
as there's currently no way to specify a per-instance default.

:param obj: the `.Thing` instance we want to reset.

:return: the default value of this property.
"""
return self._default_factory()

def reset(self, obj: Owner) -> None:
r"""Reset the property to its default value.

This resets to the value returned by ``default`` for `.DataProperty`\ .

:param obj: the `.Thing` instance we want to reset.
"""
self.__set__(obj, self.default(obj))

def _observers_set(self, obj: Thing) -> WeakSet:
"""Return the observers of this property.

Expand Down Expand Up @@ -696,11 +791,11 @@
self._fget = fget
self._type = return_type(self._fget)
if self._type is None:
msg = (

Check warning on line 794 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

794 line is not covered with tests
f"{fget} does not have a valid type. "
"Return type annotations are required for property getters."
)
raise MissingTypeError(msg)

Check warning on line 798 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

798 line is not covered with tests
self._fset: Callable[[Owner, Value], None] | None = None
self.readonly: bool = True

Expand All @@ -723,10 +818,10 @@
:param fget: The new getter function.
:return: this descriptor (i.e. ``self``). This allows use as a decorator.
"""
self._fget = fget
self._type = return_type(self._fget)
self.__doc__ = fget.__doc__
return self

Check warning on line 824 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

821-824 lines are not covered with tests

def setter(self, fset: Callable[[Owner, Value], None]) -> Self:
r"""Set the setter function of the property.
Expand Down Expand Up @@ -798,7 +893,7 @@
# Don't return the descriptor if it's named differently.
# see typing notes in docstring.
return fset # type: ignore[return-value]
return self

Check warning on line 896 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

896 line is not covered with tests

def instance_get(self, obj: Owner) -> Value:
"""Get the value of the property.
Expand All @@ -817,7 +912,7 @@
:raises ReadOnlyPropertyError: if the property cannot be set.
"""
if self.fset is None:
raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.")

Check warning on line 915 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

915 line is not covered with tests
self.fset(obj, value)


Expand Down Expand Up @@ -863,6 +958,30 @@
raise TypeError(msg)
return cls(root=value)

@builtins.property
def default(self) -> Value: # noqa: DOC201
"""The default value of this property.

.. warning::
Note that this is an optional feature, so calling code must handle
`.FeatureNotAvailable` exceptions.
"""
return self.get_descriptor().default(self.owning_object)

@builtins.property
def is_resettable(self) -> bool: # noqa: DOC201
"""Whether the property may be reset using the ``reset()`` method."""
return self.get_descriptor().is_resettable(self.owning_object)

def reset(self) -> None:
"""Reset the property to a default value.

.. warning::
Note that this is an optional feature, so calling code must handle
`.FeatureNotAvailable` exceptions.
"""
return self.get_descriptor().reset(self.owning_object_or_error())

def validate(self, value: Any) -> Value:
"""Use the validation logic in `self.model`.

Expand Down Expand Up @@ -1044,7 +1163,7 @@

:raises NotImplementedError: this method should be implemented in subclasses.
"""
raise NotImplementedError("This method should be implemented in subclasses.")

Check warning on line 1166 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

1166 line is not covered with tests

def descriptor_info(self, owner: Owner | None = None) -> SettingInfo[Owner, Value]:
r"""Return an object that allows access to this descriptor's metadata.
Expand Down
65 changes: 64 additions & 1 deletion tests/test_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
default_factory_from_arguments,
)
from labthings_fastapi.base_descriptor import DescriptorAddedToClassTwiceError
from labthings_fastapi.exceptions import MissingTypeError, NotConnectedToServerError
from labthings_fastapi.exceptions import (
FeatureNotAvailable,
MissingTypeError,
NotBoundToInstanceError,
NotConnectedToServerError,
)
import labthings_fastapi as lt
from labthings_fastapi.testing import create_thing_without_server
from .utilities import raises_or_is_caused_by
Expand Down Expand Up @@ -456,3 +461,61 @@ def _set_funcprop(self, val: int) -> None:
"ro_functional_property_with_setter",
]:
assert td.properties[name].readOnly is True


def test_default_and_reset():
"""Test retrieving property defaults, and resetting to default."""

class Example(lt.Thing):
intprop: int = lt.property(default=42)
listprop: list[str] = lt.property(default_factory=lambda: ["a", "list"])

@lt.property
def strprop(self) -> str:
return "Hello World!"

example = create_thing_without_server(Example)

# Defaults should be available on classes and instances
for thing in [example, Example]:
# We should get expected values for defaults
assert thing.properties["intprop"].default == 42
assert thing.properties["listprop"].default == ["a", "list"]
# Defaults are not available for FunctionalProperties
with pytest.raises(FeatureNotAvailable):
_ = thing.properties["strprop"].default

# Resetting to default isn't available on classes
for name in ["intprop", "listprop", "strprop"]:
with pytest.raises(NotBoundToInstanceError):
thing.properties[name].reset()

# Check the `resettable` property is correct
for thing in [example, Example]:
for name, resettable in [
("intprop", True),
("listprop", True),
("strprop", False),
]:
assert thing.properties[name].is_resettable is resettable

# Resetting should work for DataProperty
example.intprop = 43
assert example.intprop == 43
example.properties["intprop"].reset()
assert example.intprop == 42

example.listprop = []
assert example.listprop == []
example.properties["listprop"].reset()
assert example.listprop == ["a", "list"]

# Resetting won't work for FunctionalProperty
with pytest.raises(FeatureNotAvailable):
example.properties["strprop"].reset()

# Check defaults show up in the Thing Description
td = example.thing_description_dict()
assert td["properties"]["intprop"]["default"] == 42
assert td["properties"]["listprop"]["default"] == ["a", "list"]
assert "default" not in td["properties"]["strprop"]