From f9269fb8597eee619b224e21ce20057c715bb0b0 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 24 Nov 2025 21:20:46 +0000 Subject: [PATCH 1/6] Accept constraint arguments in property. `lt.property` now passes some keyword arguments on to `pydantic.Field`. This allows basic numeric constraints to be added to properties. Adding regex patterns and length constraints to strings is also allowed. This is limited at present: * Constraints are only checked when values are set over HTTP, not python. * Constraints are only supported on DataProperties * We don't proactively check constraints are OK, they will only be checked when the model is created. This happens lazily at present. Adding constraints to settings should be trivial. Doing so for functional properties/settings will require a decorator with arguments, which is trickier but possible. --- src/labthings_fastapi/exceptions.py | 11 ++ src/labthings_fastapi/properties.py | 78 ++++++++++-- src/labthings_fastapi/utilities/__init__.py | 36 +++++- tests/test_properties.py | 125 +++++++++++++++++++- 4 files changed, 238 insertions(+), 12 deletions(-) diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index c00c0adc..a21246c7 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -145,3 +145,14 @@ class NoBlobManagerError(RuntimeError): Any access to an invocation output must have BlobIOContextDep as a dependency, as the output may be a blob, and the blob needs this context to resolve its URL. """ + + +class UnsupportedConstraintError(ValueError): + """A constraint argument is not supported. + + This exception is raised when a constraint argument is passed to + a property that is not in the supported list. See + `labthings_fastapi.properties.CONSTRAINT_ARGS` for the list of + supported arguments. Their meaning is described in the `pydantic.Field` + documentation. + """ diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 9ce12150..2cf87d26 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -47,6 +47,7 @@ class attribute. Documentation is in strings immediately following the from __future__ import annotations import builtins +from collections.abc import Mapping from types import EllipsisType from typing import ( Annotated, @@ -78,6 +79,7 @@ class attribute. Documentation is in strings immediately following the NotConnectedToServerError, ReadOnlyPropertyError, MissingTypeError, + UnsupportedConstraintError, ) if TYPE_CHECKING: @@ -99,6 +101,20 @@ class attribute. Documentation is in strings immediately following the # builtins.property as a property decorator. +CONSTRAINT_ARGS = { + "gt", + "ge", + "lt", + "le", + "multiple_of", + "allow_inf_nan", + "min_length", + "max_length", + "pattern", +} +"""The set of supported constraint arguments for properties.""" + + # The following exceptions are raised only when creating/setting up properties. class OverspecifiedDefaultError(ValueError): """The default value has been specified more than once. @@ -198,12 +214,14 @@ def property( @overload # use as `field: int = property(default=0)` -def property(*, default: Value, readonly: bool = False) -> Value: ... +def property( + *, default: Value, readonly: bool = False, **constraints: Any +) -> Value: ... @overload # use as `field: int = property(default_factory=lambda: 0)` def property( - *, default_factory: Callable[[], Value], readonly: bool = False + *, default_factory: Callable[[], Value], readonly: bool = False, **constraints: Any ) -> Value: ... @@ -213,6 +231,7 @@ def property( default: Value | EllipsisType = ..., default_factory: ValueFactory | None = None, readonly: bool = False, + **constraints: Any, ) -> Value | FunctionalProperty[Value]: r"""Define a Property on a `.Thing`\ . @@ -243,6 +262,11 @@ def property( a `.DirectThingClient`). This is automatically true if ``property`` is used as a decorator and no setter is specified. + :param \**constraints: additional keyword arguments are passed + to `pydantic.Field` and allow constraints to be added to the + property. For example, ``ge=0`` constrains a numeric property + to be non-negative. See `pydantic.Field` for the full range + of constraint arguments. :return: a property descriptor, either a `.FunctionalProperty` if used as a decorator, or a `.DataProperty` if used as @@ -300,6 +324,7 @@ def property( return DataProperty( # type: ignore[return-value] default_factory=default_factory_from_arguments(default, default_factory), readonly=readonly, + constraints=constraints, ) @@ -314,11 +339,27 @@ class BaseProperty(FieldTypedBaseDescriptor[Value], Generic[Value]): use `.property` to declare properties on your `.Thing` subclass. """ - def __init__(self) -> None: - """Initialise a BaseProperty.""" + def __init__(self, constraints: Mapping[str, Any] | None = None) -> None: + """Initialise a BaseProperty. + + :param constraints: is passed as keyword arguments to `pydantic.Field` + to add validation constraints to the property. See `pydantic.Field` + for details. The module-level constant `CONSTRAINT_ARGS` lists + the supported constraint arguments. + + :raises UnsupportedConstraintError: if unsupported constraint arguments + are supplied. See `CONSTRAINT_ARGS` for the supported arguments. + """ super().__init__() self._model: type[BaseModel] | None = None self.readonly: bool = False + self._constraints = constraints or {} + for key in self._constraints: + if key not in CONSTRAINT_ARGS: + raise UnsupportedConstraintError( + f"Unknown constraint argument: {key}. \n" + f"Supported arguments are: {', '.join(CONSTRAINT_ARGS)}." + ) @builtins.property def model(self) -> type[BaseModel]: @@ -335,7 +376,10 @@ def model(self) -> type[BaseModel]: :return: a Pydantic model for the property's type. """ if self._model is None: - self._model = wrap_plain_types_in_rootmodel(self.value_type) + self._model = wrap_plain_types_in_rootmodel( + self.value_type, + constraints=self._constraints, + ) return self._model def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: @@ -452,12 +496,20 @@ class DataProperty(BaseProperty[Value], Generic[Value]): @overload def __init__( # noqa: DOC101,DOC103 - self, default: Value, *, readonly: bool = False + self, + default: Value, + *, + readonly: bool = False, + constraints: Mapping[str, Any] | None = None, ) -> None: ... @overload def __init__( # noqa: DOC101,DOC103 - self, *, default_factory: ValueFactory, readonly: bool = False + self, + *, + default_factory: ValueFactory, + readonly: bool = False, + constraints: Mapping[str, Any] | None = None, ) -> None: ... def __init__( @@ -466,6 +518,7 @@ def __init__( *, default_factory: ValueFactory | None = None, readonly: bool = False, + constraints: Mapping[str, Any] | None = None, ) -> None: """Create a property that acts like a regular variable. @@ -496,8 +549,11 @@ def __init__( :param readonly: if ``True``, the property may not be written to via HTTP, or via `.DirectThingClient` objects, i.e. it may only be set as an attribute of the `.Thing` and not from a client. + :param constraints: is passed as keyword arguments to `pydantic.Field` + to add validation constraints to the property. See `pydantic.Field` + for details. """ - super().__init__() + super().__init__(constraints=constraints) self._default_factory = default_factory_from_arguments( default=default, default_factory=default_factory ) @@ -596,6 +652,7 @@ class FunctionalProperty(BaseProperty[Value], Generic[Value]): def __init__( self, fget: ValueGetter, + constraints: Mapping[str, Any] | None = None, ) -> None: """Set up a FunctionalProperty. @@ -605,10 +662,13 @@ def __init__( tools understand that it functions like a property. :param fget: the getter function, called when the property is read. + :param constraints: is passed as keyword arguments to `pydantic.Field` + to add validation constraints to the property. See `pydantic.Field` + for details. :raises MissingTypeError: if the getter does not have a return type annotation. """ - super().__init__() + super().__init__(constraints=constraints) self._fget: ValueGetter = fget self._type = return_type(self._fget) if self._type is None: diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index f1964e94..ab480acc 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -1,10 +1,14 @@ """Utility functions used by LabThings-FastAPI.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, Dict, Iterable, TYPE_CHECKING, Optional from weakref import WeakSet from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model from pydantic.dataclasses import dataclass +from pydantic_core import SchemaError + +from labthings_fastapi.exceptions import UnsupportedConstraintError from .introspection import EmptyObject if TYPE_CHECKING: @@ -81,7 +85,9 @@ def labthings_data(obj: Thing) -> LabThingsObjectData: return obj.__dict__[LABTHINGS_DICT_KEY] -def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel]: +def wrap_plain_types_in_rootmodel( + model: type, constraints: Mapping[str, Any] | None = None +) -> type[BaseModel]: """Ensure a type is a subclass of BaseModel. If a `pydantic.BaseModel` subclass is passed to this function, we will pass it @@ -90,15 +96,41 @@ def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel]: and not a model instance. :param model: A Python type or `pydantic` model. + :param constraints: is passed as keyword arguments to `pydantic.Field` + to add validation constraints to the property. :return: A `pydantic` model, wrapping Python types in a ``RootModel`` if needed. + + :raises UnsupportedConstraintError: if constraints are provided for an + unsuitable type, for example `allow_inf_nan` for an `int` property, or + any constraints for a `BaseModel` subclass. + :raises SchemaError: if other errors prevent Pydantic from creating a schema + for the generated model. """ try: # This needs to be a `try` as basic types are not classes if issubclass(model, BaseModel): + if constraints: + raise UnsupportedConstraintError( + "Constraints may only be applied to plain types, not Models." + ) return model except TypeError: pass # some plain types aren't classes and that's OK - they still get wrapped. - return create_model(f"{model!r}", root=(model, ...), __base__=RootModel) + constraints = constraints or {} + try: + return create_model( + f"{model!r}", + root=(model, Field(**constraints)), + __base__=RootModel, + ) + except SchemaError as e: + for error in e.errors(): + if error["loc"][-1] in constraints: + key = error["loc"][-1] + raise UnsupportedConstraintError( + f"Constraint {key} is not supported for type {model!r}." + ) from e + raise e def model_to_dict(model: Optional[BaseModel]) -> Dict[str, Any]: diff --git a/tests/test_properties.py b/tests/test_properties.py index ca8bba0a..966224c0 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,12 +1,16 @@ from threading import Thread from typing import Any +from annotated_types import Ge, Le, Gt, Lt, MultipleOf, MinLen, MaxLen from pydantic import BaseModel, RootModel from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt -from labthings_fastapi.exceptions import ServerNotRunningError +from labthings_fastapi.exceptions import ( + ServerNotRunningError, + UnsupportedConstraintError, +) from .temp_client import poll_task @@ -49,6 +53,17 @@ def toggle_boolprop_from_thread(self): # Ensure the thread has finished before the action completes: t.join() + constrained_int: int = lt.property(default=5, ge=0, le=10, multiple_of=2) + "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" + + constrained_str: str = lt.property( + default="hello", min_length=3, max_length=10, pattern="^[a-z]+$" + ) + "A string property with constraints" + @pytest.fixture def server(): @@ -242,3 +257,111 @@ def test_setting_without_event_loop(): assert isinstance(thing, PropertyTestThing) with pytest.raises(ServerNotRunningError): thing.boolprop = False # Can't call it until the event loop's running + + +def test_constrained_properties(): + """Test that constraints on property values generate correct models.""" + constrained_int = PropertyTestThing.constrained_int + assert constrained_int.value_type is int + m = constrained_int.model + assert issubclass(m, RootModel) + for ann in [Ge(0), Le(10), MultipleOf(2)]: + assert any(meta == ann for meta in m.model_fields["root"].metadata) + + constrained_float = PropertyTestThing.constrained_float + assert constrained_float.value_type is float + m = constrained_float.model + assert issubclass(m, RootModel) + for ann in [Gt(0), Lt(10)]: + assert any(meta == ann for meta in m.model_fields["root"].metadata) + + constrained_str = PropertyTestThing.constrained_str + assert constrained_str.value_type is str + m = constrained_str.model + assert issubclass(m, RootModel) + for ann in [MinLen(3), MaxLen(10)]: + assert any(meta == ann for meta in m.model_fields["root"].metadata) + + +def test_constrained_properties_http(server): + """Test properties with constraints on their values. + + This tests that the constraints are enforced when setting + the properties via HTTP PUT requests. + + It also checks that the constraints propagate to the JSONSchema. + """ + with TestClient(server.app) as client: + r = client.get("/thing/") + r.raise_for_status() + thing_description = r.json() + properties = thing_description["properties"] + + # Test constrained_int + r = client.put("/thing/constrained_int", json=8) + assert r.status_code == 201 # Successful write + r = client.put("/thing/constrained_int", json=11) + assert r.status_code == 422 # Above 'le' constraint + r = client.put("/thing/constrained_int", json=-2) + assert r.status_code == 422 # Below 'ge' constraint + r = client.put("/thing/constrained_int", json=5) + assert r.status_code == 422 # Not a multiple_of 2 + property = properties["constrained_int"] + assert property["minimum"] == 0 + assert property["maximum"] == 10 + assert property["multipleOf"] == 2 + + # Test constrained_float + r = client.put("/thing/constrained_float", json=5.5) + assert r.status_code == 201 # Successful write + r = client.put("/thing/constrained_float", json=10.0) + assert r.status_code == 422 # Not less than 'lt' constraint + r = client.put("/thing/constrained_float", json=0.0) + assert r.status_code == 422 # Not greater than 'gt' constraint + r = client.put("/thing/constrained_float", json="Infinity") + assert r.status_code == 422 # inf not allowed + property = properties["constrained_float"] + assert property["exclusiveMaximum"] == 10.0 + assert property["exclusiveMinimum"] == 0.0 + + # Check unconstrained float allows inf, so we know the test + # above is different from the default case. + r = client.put("/thing/floatprop", json="Infinity") + assert r.status_code == 201 # inf is allowed for unconstrained float + + # Test constrained_str + r = client.put("/thing/constrained_str", json="valid") + assert r.status_code == 201 # Successful write + r = client.put("/thing/constrained_str", json="no") + assert r.status_code == 422 # Below min_length + r = client.put("/thing/constrained_str", json="thisisaverylongstring") + assert r.status_code == 422 # Above max_length + r = client.put("/thing/constrained_str", json="Invalid1") + assert r.status_code == 422 # Does not match pattern + property = properties["constrained_str"] + assert property["minLength"] == 3 + assert property["maxLength"] == 10 + assert property["pattern"] == "^[a-z]+$" + + +def test_bad_property_constraints(): + """Test that bad constraints raise errors at definition time.""" + + class BadConstraintThing(lt.Thing): + bad_prop: int = lt.property(default=0, allow_inf_nan=False) + + # Some constraints cause errors when the model is built. So far + # I believe only allow_inf_nan on int does this. + with pytest.raises(UnsupportedConstraintError): + _ = BadConstraintThing.bad_prop.model + + # Other bad constraints raise errors when the property is created. + # This should happen for any argument not in CONSTRAINT_ARGS + with pytest.raises(UnsupportedConstraintError): + + class AnotherBadConstraintThing(lt.Thing): + another_bad_prop: str = lt.property(default="foo", bad_constraint=2) + + # Some in appropriate 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. From 6acb19b7db762bf571eea282120b5e46ba37a4db Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 26 Nov 2025 12:14:04 +0000 Subject: [PATCH 2/6] Add constraint arguments to `lt.setting` This change also makes `constraints` a public attribute, allowing `FunctionalProperty` (or setting) instances to have constraints set. Both settings and properties, data and functional, are tested. --- src/labthings_fastapi/properties.py | 35 ++++- tests/test_properties.py | 230 +++++++++++++++++++--------- 2 files changed, 187 insertions(+), 78 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 2cf87d26..e0566b05 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -305,7 +305,7 @@ def property( value is passed as the first argument. """ if getter is not ...: - # If the default is callable, we're being used as a decorator + # If the getter argument is callable, we're being used as a decorator # without arguments. if not callable(getter): raise MissingDefaultError( @@ -353,14 +353,28 @@ def __init__(self, constraints: Mapping[str, Any] | None = None) -> None: super().__init__() self._model: type[BaseModel] | None = None self.readonly: bool = False - self._constraints = constraints or {} - for key in self._constraints: + self.constraints = constraints or {} + for key in self.constraints: if key not in CONSTRAINT_ARGS: raise UnsupportedConstraintError( f"Unknown constraint argument: {key}. \n" f"Supported arguments are: {', '.join(CONSTRAINT_ARGS)}." ) + constraints: Mapping[str, Any] + """Validation constraints applied to this property. + + This mapping contains keyword arguments that will be passed to + `pydantic.Field` to add validation constraints to the property. + See `pydantic.Field` for details. The module-level constant + `CONSTRAINT_ARGS` lists the supported constraint arguments. + + Note that these constraints will be enforced when values are + received over HTTP, but they are not automatically enforced + when setting the property directly on the `.Thing` instance + from Python code. + """ + @builtins.property def model(self) -> type[BaseModel]: """A Pydantic model for the property's type. @@ -378,7 +392,7 @@ def model(self) -> type[BaseModel]: if self._model is None: self._model = wrap_plain_types_in_rootmodel( self.value_type, - constraints=self._constraints, + constraints=self.constraints, ) return self._model @@ -804,12 +818,12 @@ def setting( @overload # use as `field: int = setting(default=0)`` -def setting(*, default: Value, readonly: bool = False) -> Value: ... +def setting(*, default: Value, readonly: bool = False, **constraints: Any) -> Value: ... @overload # use as `field: int = setting(default_factory=lambda: 0)` def setting( - *, default_factory: Callable[[], Value], readonly: bool = False + *, default_factory: Callable[[], Value], readonly: bool = False, **constraints: Any ) -> Value: ... @@ -819,6 +833,7 @@ def setting( default: Value | EllipsisType = ..., default_factory: ValueFactory | None = None, readonly: bool = False, + **constraints: Any, ) -> FunctionalSetting[Value] | Value: r"""Define a Setting on a `.Thing`\ . @@ -864,6 +879,11 @@ def setting( :param readonly: whether the setting should be read-only via the `.ThingClient` interface (i.e. over HTTP or via a `.DirectThingClient`). + :param \**constraints: additional keyword arguments are passed + to `pydantic.Field` and allow constraints to be added to the + property. For example, ``ge=0`` constrains a numeric property + to be non-negative. See `pydantic.Field` for the full range + of constraint arguments. :return: a setting descriptor. @@ -877,7 +897,7 @@ def setting( well. """ if getter is not ...: - # If the default is callable, we're being used as a decorator + # If the getter argument is callable, we're being used as a decorator # without arguments. if not callable(getter): raise MissingDefaultError( @@ -896,6 +916,7 @@ def setting( return DataSetting( # type: ignore[return-value] default_factory=default_factory_from_arguments(default, default_factory), readonly=readonly, + constraints=constraints, ) diff --git a/tests/test_properties.py b/tests/test_properties.py index 966224c0..2d9a9f15 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,8 +1,10 @@ +from dataclasses import dataclass +from tempfile import TemporaryDirectory from threading import Thread from typing import Any from annotated_types import Ge, Le, Gt, Lt, MultipleOf, MinLen, MaxLen -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, RootModel, ValidationError from fastapi.testclient import TestClient import pytest @@ -11,10 +13,18 @@ ServerNotRunningError, UnsupportedConstraintError, ) +from labthings_fastapi.properties import BaseProperty from .temp_client import poll_task class PropertyTestThing(lt.Thing): + """A Thing with various properties for testing.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._constrained_functional_int = 0 + self._constrained_functional_str_setting = "ddd" + boolprop: bool = lt.property(default=False) "A boolean property" @@ -64,11 +74,117 @@ def toggle_boolprop_from_thread(self): ) "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" + + @lt.property + def constrained_functional_int(self) -> int: + return self._constrained_functional_int + + @constrained_functional_int.setter + def set_constrained_functional_int(self, value: int): + self._constrained_functional_int = value + + constrained_functional_int.constraints = {"ge": 0, "le": 10} + + @lt.setting + def constrained_functional_str_setting(self) -> str: + return self._constrained_functional_str_setting + + @constrained_functional_str_setting.setter + def set_constrained_functional_str_setting(self, value: str): + self._constrained_functional_str_setting = value + + constrained_functional_str_setting.constraints = { + "min_length": 3, + "max_length": 10, + "pattern": "^d+$", + } + + +@dataclass +class ConstrainedPropInfo: + name: str + prop: BaseProperty + value_type: type + constraints: list[Any] + valid_values: list[Any] + invalid_values: list[Any] + jsonschema_constraints: dict[str, Any] + + +CONSTRAINED_PROPS = [ + ConstrainedPropInfo( + name="constrained_int", + prop=PropertyTestThing.constrained_int, + value_type=int, + constraints=[Ge(0), Le(10), MultipleOf(2)], + valid_values=[0, 2, 4, 6, 8, 10], + invalid_values=[-2, 11, 3], + jsonschema_constraints={"minimum": 0, "maximum": 10, "multipleOf": 2}, + ), + ConstrainedPropInfo( + name="constrained_float", + prop=PropertyTestThing.constrained_float, + value_type=float, + constraints=[Gt(0), Lt(10)], + valid_values=[0.1, 5.0, 9.9], + invalid_values=[0.0, 10.0, -1.0, float("inf")], + jsonschema_constraints={"exclusiveMinimum": 0.0, "exclusiveMaximum": 10.0}, + ), + ConstrainedPropInfo( # This checks "inf" is OK by default. + name="floatprop", + prop=PropertyTestThing.floatprop, + value_type=float, + constraints=[], + valid_values=[-1.0, 0.0, 1.0, float("inf"), float("-inf"), float("nan")], + invalid_values=[], + jsonschema_constraints={}, + ), + ConstrainedPropInfo( + name="constrained_str", + prop=PropertyTestThing.constrained_str, + value_type=str, + constraints=[MinLen(3), MaxLen(10)], + valid_values=["abc", "ddddd", "tencharsaa"], + invalid_values=["d", "ab", "verylongstring", "Invalid1"], + jsonschema_constraints={"minLength": 3, "maxLength": 10, "pattern": "^[a-z]+$"}, + ), + ConstrainedPropInfo( + name="constrained_int_setting", + prop=PropertyTestThing.constrained_int_setting, + value_type=int, + constraints=[Ge(0), Le(10), MultipleOf(2)], + valid_values=[0, 2, 4, 6, 8, 10], + invalid_values=[-2, 11, 3], + jsonschema_constraints={"minimum": 0, "maximum": 10, "multipleOf": 2}, + ), + ConstrainedPropInfo( + name="constrained_functional_int", + prop=PropertyTestThing.constrained_functional_int, + value_type=int, + constraints=[Ge(0), Le(10)], + valid_values=[0, 5, 10], + invalid_values=[-1, 11], + jsonschema_constraints={"minimum": 0, "maximum": 10}, + ), + ConstrainedPropInfo( + name="constrained_functional_str_setting", + prop=PropertyTestThing.constrained_functional_str_setting, + value_type=str, + constraints=[MinLen(3), MaxLen(10)], + valid_values=["ddd", "dddd"], + invalid_values=["dd", "thisisaverylongstring", "abc"], + jsonschema_constraints={"minLength": 3, "maxLength": 10, "pattern": "^d+$"}, + ), +] + @pytest.fixture def server(): - server = lt.ThingServer({"thing": PropertyTestThing}) - return server + with TemporaryDirectory() as dirpath: + server = lt.ThingServer({"thing": PropertyTestThing}, settings_folder=dirpath) + yield server def test_types_are_found(): @@ -259,31 +375,38 @@ def test_setting_without_event_loop(): thing.boolprop = False # Can't call it until the event loop's running -def test_constrained_properties(): +@pytest.mark.parametrize("prop_info", CONSTRAINED_PROPS) +def test_constrained_properties(prop_info): """Test that constraints on property values generate correct models.""" - constrained_int = PropertyTestThing.constrained_int - assert constrained_int.value_type is int - m = constrained_int.model - assert issubclass(m, RootModel) - for ann in [Ge(0), Le(10), MultipleOf(2)]: - assert any(meta == ann for meta in m.model_fields["root"].metadata) - - constrained_float = PropertyTestThing.constrained_float - assert constrained_float.value_type is float - m = constrained_float.model + prop = prop_info.prop + assert prop.value_type is prop_info.value_type + m = prop.model assert issubclass(m, RootModel) - for ann in [Gt(0), Lt(10)]: + for ann in prop_info.constraints: assert any(meta == ann for meta in m.model_fields["root"].metadata) - - constrained_str = PropertyTestThing.constrained_str - assert constrained_str.value_type is str - m = constrained_str.model - assert issubclass(m, RootModel) - for ann in [MinLen(3), MaxLen(10)]: - assert any(meta == ann for meta in m.model_fields["root"].metadata) - - -def test_constrained_properties_http(server): + for valid in prop_info.valid_values: + instance = m(root=valid) + validated = instance.model_dump() + assert validated == valid or validated is valid # `is` for NaN + for invalid in prop_info.invalid_values: + with pytest.raises(ValidationError): + _ = m(root=invalid) + + +def convert_inf_nan(value): + """Replace `float(`inf)` and `float(-inf)` with strings.""" + if value == float("inf"): + return "Infinity" + elif value == float("-inf"): + return "-Infinity" + elif value != value: # NaN check + return "NaN" + else: + return value + + +@pytest.mark.parametrize("prop_info", CONSTRAINED_PROPS) +def test_constrained_properties_http(server, prop_info): """Test properties with constraints on their values. This tests that the constraints are enforced when setting @@ -297,51 +420,16 @@ def test_constrained_properties_http(server): thing_description = r.json() properties = thing_description["properties"] - # Test constrained_int - r = client.put("/thing/constrained_int", json=8) - assert r.status_code == 201 # Successful write - r = client.put("/thing/constrained_int", json=11) - assert r.status_code == 422 # Above 'le' constraint - r = client.put("/thing/constrained_int", json=-2) - assert r.status_code == 422 # Below 'ge' constraint - r = client.put("/thing/constrained_int", json=5) - assert r.status_code == 422 # Not a multiple_of 2 - property = properties["constrained_int"] - assert property["minimum"] == 0 - assert property["maximum"] == 10 - assert property["multipleOf"] == 2 - - # Test constrained_float - r = client.put("/thing/constrained_float", json=5.5) - assert r.status_code == 201 # Successful write - r = client.put("/thing/constrained_float", json=10.0) - assert r.status_code == 422 # Not less than 'lt' constraint - r = client.put("/thing/constrained_float", json=0.0) - assert r.status_code == 422 # Not greater than 'gt' constraint - r = client.put("/thing/constrained_float", json="Infinity") - assert r.status_code == 422 # inf not allowed - property = properties["constrained_float"] - assert property["exclusiveMaximum"] == 10.0 - assert property["exclusiveMinimum"] == 0.0 - - # Check unconstrained float allows inf, so we know the test - # above is different from the default case. - r = client.put("/thing/floatprop", json="Infinity") - assert r.status_code == 201 # inf is allowed for unconstrained float - - # Test constrained_str - r = client.put("/thing/constrained_str", json="valid") - assert r.status_code == 201 # Successful write - r = client.put("/thing/constrained_str", json="no") - assert r.status_code == 422 # Below min_length - r = client.put("/thing/constrained_str", json="thisisaverylongstring") - assert r.status_code == 422 # Above max_length - r = client.put("/thing/constrained_str", json="Invalid1") - assert r.status_code == 422 # Does not match pattern - property = properties["constrained_str"] - assert property["minLength"] == 3 - assert property["maxLength"] == 10 - assert property["pattern"] == "^[a-z]+$" + for valid in prop_info.valid_values: + r = client.put(f"/thing/{prop_info.name}", json=convert_inf_nan(valid)) + err = f"Failed to set {prop_info.name}={valid}, {r.json()}" + assert r.status_code == 201, err # 201 means it was set successfully + for invalid in prop_info.invalid_values: + r = client.put(f"/thing/{prop_info.name}", json=convert_inf_nan(invalid)) + assert r.status_code == 422 # Unprocessable entity - invalid value + property = properties[prop_info.name] + for key, value in prop_info.jsonschema_constraints.items(): + assert property[key] == value def test_bad_property_constraints(): @@ -362,6 +450,6 @@ class BadConstraintThing(lt.Thing): class AnotherBadConstraintThing(lt.Thing): another_bad_prop: str = lt.property(default="foo", bad_constraint=2) - # Some in appropriate constraints (e.g. multiple_of on str) are passed through + # 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. From 04f7450e5c7aafa853c342c24c493e61d9e67103 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 26 Nov 2025 12:31:06 +0000 Subject: [PATCH 3/6] Add constraints to property docs This also moves the "properties" page out of the tutorial: it's too detailed for that. The new structure is much closer to my original intent. --- docs/source/index.rst | 5 ++- docs/source/{tutorial => }/properties.rst | 48 ++++++++++++++++++++++- docs/source/tutorial/index.rst | 3 -- docs/source/tutorial/writing_a_thing.rst | 34 +++++++++++++++- 4 files changed, 82 insertions(+), 8 deletions(-) rename docs/source/{tutorial => }/properties.rst (77%) diff --git a/docs/source/index.rst b/docs/source/index.rst index 90100a63..9cbcb8c4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,15 +8,16 @@ Documentation for LabThings-FastAPI quickstart/quickstart.rst tutorial/index.rst structure.rst - examples.rst - documentation.rst actions.rst + properties.rst + documentation.rst thing_slots.rst dependencies/dependencies.rst blobs.rst concurrency.rst using_things.rst see_also.rst + examples.rst wot_core_concepts.rst autoapi/index diff --git a/docs/source/tutorial/properties.rst b/docs/source/properties.rst similarity index 77% rename from docs/source/tutorial/properties.rst rename to docs/source/properties.rst index ebdff0e7..32037601 100644 --- a/docs/source/tutorial/properties.rst +++ b/docs/source/properties.rst @@ -1,4 +1,3 @@ -.. _tutorial_properties: .. _properties: Properties @@ -117,6 +116,53 @@ In the example above, ``twice_my_property`` may be set by code within ``MyThing` Functional properties may not be observed, as they are not backed by a simple value. If you need to notify clients when the value changes, you can use a data property that is updated by the functional property. In the example above, ``my_property`` may be observed, while ``twice_my_property`` cannot be observed. It would be possible to observe changes in ``my_property`` and then query ``twice_my_property`` for its new value. +.. _property_constraints: + +Property constraints +-------------------- + +It's often helpful to make it clear that there are limits on the values a property can take. For example, a temperature property might only be valid between -40 and 125 degrees Celsius. LabThings allows you to specify constraints on properties using the same arguments as `pydantic` `Field`_ definitions. These constraints will be enforced when the property is written to via HTTP, and they will also appear in the :ref:`gen_td` and :ref:`gen_docs`. The module-level constant `.property.CONSTRAINT_ARGS` lists all supported constraint arguments. + +We can modify the previous example to show how to add constraints to both data and functional properties: + +.. code-block:: python + + import labthings_fastapi as lt + + class AirSensor(lt.Thing): + temperature: float = lt.property( + default=20.0, + ge=-40.0, # Greater than or equal to -40.0 + le=125.0 # Less than or equal to 125.0 + ) + """The current temperature in degrees Celsius.""" + + @lt.property + def humidity(self) -> float: + """The current humidity percentage.""" + return self._humidity + + @humidity.setter + def humidity(self, value: float): + """Set the current humidity percentage.""" + self._humidity = value + + # Add constraints to the functional property + humidity.constraints = { + "ge": 0.0, # Greater than or equal to 0.0 + "le": 100.0 # Less than or equal to 100.0 + } + + sensor_name: str = lt.property(default="my_sensor", pattern="^[a-zA-Z0-9_]+$") + +In the example above, the ``temperature`` property is a data property with constraints that limit its value to between -40.0 and 125.0 degrees Celsius. The ``humidity`` property is a functional property with constraints that limit its value to between 0.0 and 100.0 percent. The ``sensor_name`` property is a data property with a regex pattern constraint that only allows alphanumeric characters and underscores. + +Note that the constraints for functional properties are set by assigning a dictionary to the property's ``constraints`` attribute. This dictionary should contain the same keys and values as the arguments to `pydantic` `Field`_ definitions. The `.property` decorator does not currently accept arguments, so constraints may only be set this way for functional properties and settings. + +.. note:: + + Property values are not validated when they are set directly, only via HTTP. This behaviour may change in the future. + HTTP interface -------------- diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst index 7bd46ef2..d75a92ab 100644 --- a/docs/source/tutorial/index.rst +++ b/docs/source/tutorial/index.rst @@ -8,14 +8,11 @@ LabThings-FastAPI tutorial installing_labthings.rst running_labthings.rst writing_a_thing.rst - properties.rst .. In due course, these pages should exist... - writing_a_thing.rst client_code.rst blobs.rst - thing_dependencies.rst In this tutorial, we'll cover how to start up and interact with a LabThings-FastAPI server. diff --git a/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst index 776923e0..60e49789 100644 --- a/docs/source/tutorial/writing_a_thing.rst +++ b/docs/source/tutorial/writing_a_thing.rst @@ -18,7 +18,7 @@ Our first Thing will pretend to be a light: we can set its brightness and turn i class Light(lt.Thing): """A computer-controlled light, our first example Thing.""" - brightness: int = lt.property(default=100) + brightness: int = lt.property(default=100, ge=0, le=100) """The brightness of the light, in % of maximum.""" is_on: bool = lt.property(default=False, readonly=true) @@ -39,4 +39,34 @@ Our first Thing will pretend to be a light: we can set its brightness and turn i If you visit `http://localhost:5000/light`, you will see the Thing Description. You can also interact with it using the OpenAPI documentation at `http://localhost:5000/docs`. If you visit `http://localhost:5000/light/brightness`, you can set the brightness of the light, and if you visit `http://localhost:5000/light/is_on`, you can see whether the light is on. Changing values on the server requires a ``PUT`` or ``POST`` request, which is easiest to do using the OpenAPI "Try it out" feature. Check that you can use a ``POST`` request to the ``toggle`` endpoint to turn the light on and off. -There are two types of :ref:`wot_affordances` in this example: properties and actions. Properties are used to read and write values, while actions are used to perform operations that change the state of the Thing. In this case, we have a property for the brightness of the light and a property to indicate whether the light is on or off. The action ``toggle`` changes the state of the light by toggling the ``is_on`` property between ``True`` and ``False``. \ No newline at end of file +This example has both properties and actions. Properties are used to read and write values, while actions are used to perform operations that change the state of the Thing. + +.. _tutorial_properties: + +Properties +---------- + +We have a property for the brightness of the light and a property to indicate whether the light is on or off. These are both "data properties" because they function just like variables. + +``is_on`` is a boolean value, so it may be either `True` or `False`. ``brightness`` is an integer, and the `ge` and `le` arguments constrain it to take a value between 0 and 100. See :ref:`property_constraints` for more details. + +It's also possible to have properties defined using a function, for example we could add in: + +.. code-block:: python + + @lt.property + def status(self) -> str: + """A human-readable status of the light.""" + if self.is_on: + return f"The light is on at {self.brightness}% brightness." + else: + return "The light is off." + +This is a "functional property" because its value is determined by a function. Functional properties may be read-only (as in this example) or read-write (see :ref:`properties`). + +.. _tutorial_actions: + +Actions +----------- + +The action ``toggle`` changes the state of the light by toggling the ``is_on`` property between ``True`` and ``False``. For more detail on how actions work, see :ref:`actions`. From 3252682fc9cb93914d2d16116f51a8e480a52b3b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 26 Nov 2025 13:03:18 +0000 Subject: [PATCH 4/6] Docs fixes Fixed a dead link and added an `__all__` to stop exceptions being duplicated. --- docs/source/properties.rst | 4 ++-- src/labthings_fastapi/utilities/__init__.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/source/properties.rst b/docs/source/properties.rst index 32037601..23299af2 100644 --- a/docs/source/properties.rst +++ b/docs/source/properties.rst @@ -121,7 +121,7 @@ Functional properties may not be observed, as they are not backed by a simple va Property constraints -------------------- -It's often helpful to make it clear that there are limits on the values a property can take. For example, a temperature property might only be valid between -40 and 125 degrees Celsius. LabThings allows you to specify constraints on properties using the same arguments as `pydantic` `Field`_ definitions. These constraints will be enforced when the property is written to via HTTP, and they will also appear in the :ref:`gen_td` and :ref:`gen_docs`. The module-level constant `.property.CONSTRAINT_ARGS` lists all supported constraint arguments. +It's often helpful to make it clear that there are limits on the values a property can take. For example, a temperature property might only be valid between -40 and 125 degrees Celsius. LabThings allows you to specify constraints on properties using the same arguments as `pydantic.Field` definitions. These constraints will be enforced when the property is written to via HTTP, and they will also appear in the :ref:`gen_td` and :ref:`gen_docs`. The module-level constant `.property.CONSTRAINT_ARGS` lists all supported constraint arguments. We can modify the previous example to show how to add constraints to both data and functional properties: @@ -157,7 +157,7 @@ We can modify the previous example to show how to add constraints to both data a In the example above, the ``temperature`` property is a data property with constraints that limit its value to between -40.0 and 125.0 degrees Celsius. The ``humidity`` property is a functional property with constraints that limit its value to between 0.0 and 100.0 percent. The ``sensor_name`` property is a data property with a regex pattern constraint that only allows alphanumeric characters and underscores. -Note that the constraints for functional properties are set by assigning a dictionary to the property's ``constraints`` attribute. This dictionary should contain the same keys and values as the arguments to `pydantic` `Field`_ definitions. The `.property` decorator does not currently accept arguments, so constraints may only be set this way for functional properties and settings. +Note that the constraints for functional properties are set by assigning a dictionary to the property's ``constraints`` attribute. This dictionary should contain the same keys and values as the arguments to `pydantic.Field` definitions. The `.property` decorator does not currently accept arguments, so constraints may only be set this way for functional properties and settings. .. note:: diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index ab480acc..d1942d1f 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -15,6 +15,16 @@ from ..thing import Thing +__all__ = [ + "class_attributes", + "attributes", + "LabThingsObjectData", + "labthings_data", + "wrap_plain_types_in_rootmodel", + "model_to_dict", +] + + def class_attributes(obj: Any) -> Iterable[tuple[str, Any]]: """List all the attributes of an object's class. From eac79dda44c0193cb83988cb9ae6a4b5347b2900 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 26 Nov 2025 13:19:51 +0000 Subject: [PATCH 5/6] Fix a failing test The additional "constraints" argument was causing a test to fail: this is now fixed. --- tests/test_property.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_property.py b/tests/test_property.py index 24321edb..3afe9b12 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -128,7 +128,8 @@ def getter(self) -> str: assert prop.args == () assert prop.kwargs["default_factory"]() == 0 assert prop.kwargs["readonly"] is False - assert len(prop.kwargs) == 2 + assert prop.kwargs["constraints"] == {} + assert len(prop.kwargs) == 3 # The same thing should happen when we use a factory, # except it should pass through the factory function unchanged. @@ -137,7 +138,8 @@ def getter(self) -> str: assert prop.args == () assert prop.kwargs["default_factory"] is list assert prop.kwargs["readonly"] is False - assert len(prop.kwargs) == 2 + assert prop.kwargs["constraints"] == {} + assert len(prop.kwargs) == 3 # The positional argument is the setter, so `None` is not valid # and probably means someone forgot to add `default=`. From 94de9530606840c1494ca4d41ae0e90c60dfdef5 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 26 Nov 2025 13:28:11 +0000 Subject: [PATCH 6/6] Fix a whitespace issue This wasn't flagged by Ruff, I should possibly just turn off the flake8 code. --- src/labthings_fastapi/properties.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index e0566b05..be1cc014 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -363,12 +363,12 @@ def __init__(self, constraints: Mapping[str, Any] | None = None) -> None: constraints: Mapping[str, Any] """Validation constraints applied to this property. - + This mapping contains keyword arguments that will be passed to `pydantic.Field` to add validation constraints to the property. See `pydantic.Field` for details. The module-level constant `CONSTRAINT_ARGS` lists the supported constraint arguments. - + Note that these constraints will be enforced when values are received over HTTP, but they are not automatically enforced when setting the property directly on the `.Thing` instance