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..23299af2 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`. 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..be1cc014 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 @@ -281,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( @@ -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,41 @@ 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)}." + ) + + 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]: @@ -335,7 +390,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 +510,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 +532,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 +563,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 +666,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 +676,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: @@ -744,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: ... @@ -759,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`\ . @@ -804,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. @@ -817,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( @@ -836,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/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index f1964e94..d1942d1f 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -1,16 +1,30 @@ """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: 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. @@ -81,7 +95,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 +106,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..2d9a9f15 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,16 +1,30 @@ +from dataclasses import dataclass +from tempfile import TemporaryDirectory from threading import Thread from typing import Any -from pydantic import BaseModel, RootModel +from annotated_types import Ge, Le, Gt, Lt, MultipleOf, MinLen, MaxLen +from pydantic import BaseModel, RootModel, ValidationError 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 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" @@ -49,11 +63,128 @@ 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" + + 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(): @@ -242,3 +373,83 @@ 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 + + +@pytest.mark.parametrize("prop_info", CONSTRAINED_PROPS) +def test_constrained_properties(prop_info): + """Test that constraints on property values generate correct models.""" + prop = prop_info.prop + assert prop.value_type is prop_info.value_type + m = prop.model + assert issubclass(m, RootModel) + for ann in prop_info.constraints: + assert any(meta == ann for meta in m.model_fields["root"].metadata) + 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 + 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"] + + 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(): + """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 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. 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=`.