Skip to content

Commit a6e5f78

Browse files
authored
Merge pull request #278 from labthings/validate-properties-on-assignment
Validate properties on assignment
2 parents 7baafe1 + 5af5eac commit a6e5f78

File tree

9 files changed

+215
-6
lines changed

9 files changed

+215
-6
lines changed

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Documentation for LabThings-FastAPI
1515
blobs.rst
1616
concurrency.rst
1717
using_things.rst
18+
updates_and_features.rst
1819
see_also.rst
1920
examples.rst
2021
wot_core_concepts.rst

docs/source/properties.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ Note that the constraints for functional properties are set by assigning a dicti
204204

205205
.. note::
206206

207-
Property values are not validated when they are set directly, only via HTTP. This behaviour may change in the future.
207+
Currently, property values are not validated when they are set directly in Python, only via HTTP.
208+
Setting ``lt.FEATURE_FLAGS.validate_properties_on_set = True`` enables validation when they are set in Python. This may become default behaviour in the future.
208209

209210
Property metadata
210211
-----------------
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.. _optional_features:
2+
3+
Optional Features and updates
4+
=============================
5+
6+
LabThings allows some features to be turned on and off globally, using the `.FEATURE_FLAGS` object.
7+
This was introduced as a way to smooth the upgrade process for downstream projects, meaning that when a new version of LabThings is released, they need not adopt all the new features at once.
8+
9+
Typically, your application will set the feature flags once, just after importing LabThings. For example, to validate properties when they are written to in Python, we would do:
10+
11+
.. code-block: python
12+
13+
import labthings_fastapi as lt
14+
15+
16+
lt.FEATURE_FLAGS.validate_properties_on_set = True
17+
18+
When new features are intended to become non-optional, the usual procedure will be:
19+
20+
* Introduce the feature in a release, but disable it by default. It may be activated by setting a flag to `True`\ .
21+
* At some point (either the release that introduces it, or a future release) a `DeprecationWarning` will be raised by relevant code if the feature has not been enabled.
22+
* A subsequent release will enable the feature by default, but it may still be disabled by setting the flag to `False`\ . This will raise a `DeprecationWarning`\ .
23+
* Another release will remove the feature flag and the feature will be permanently enabled.
24+
25+
Introducing a feature that's disabled by default, and adding `DeprecationWarning`\ s, are not "breaking changes" as they require no change to downstream code.
26+
Enabling a feature by default, or removing the ability to disable a feature, would constitute a "breaking change".
27+
While our major version is zero, the intention is that patch releases (e.g. ```0.1.0``` to ``0.1.1``) should not make breaking changes, but minor releases (e.g. ``0.1.0`` to ``0.2.0``) may do so.
28+
After `v1.0.0` LabThings should follow the Semantic Versioning convention and make breaking changes only when the major version changes.

src/labthings_fastapi/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .properties import property, setting, DataProperty, DataSetting
2626
from .actions import action
2727
from .endpoints import endpoint
28+
from .feature_flags import FEATURE_FLAGS
2829
from . import outputs
2930
from .outputs import blob
3031
from .server import ThingServer, cli
@@ -52,6 +53,7 @@
5253
"action",
5354
"thing_slot",
5455
"endpoint",
56+
"FEATURE_FLAGS",
5557
"outputs",
5658
"blob",
5759
"ThingServer",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Control of optional features in LabThings.
2+
3+
This module provides a way to globally enable or disable certain features of LabThings.
4+
When a new, non-backwards-compatible feature is added, it will usually be disabled by
5+
default. This module provides an object `.FEATURE_FLAGS` that allows control over
6+
optional features.
7+
8+
The default values of `.FEATURE_FLAGS` may change with new LabThings releases. The usual
9+
sequence of events would be that a new feature is added (disabled by default), then the
10+
feature flag is enabled by default in a subsequent release. If the intention is that the
11+
feature will become non-optional, disabling the feature will raise a
12+
`DeprecationWarning` for at least one release cycle before it is removed.
13+
"""
14+
15+
from collections.abc import Iterator
16+
from contextlib import contextmanager
17+
from dataclasses import dataclass
18+
from typing import Any
19+
20+
21+
@dataclass(slots=True)
22+
class LabThingsFeatureFlags:
23+
"""Control over optional features of LabThings."""
24+
25+
validate_properties_on_set: bool = False
26+
"""Whether validation logic runs when properties are set in Python."""
27+
28+
@contextmanager
29+
def set_temporarily(
30+
self,
31+
**kwargs: Any,
32+
) -> Iterator[None]:
33+
r"""Temporarily set feature flags in a context manager.
34+
35+
This function may be used in a `with:` block to set feature flags and
36+
then reset them afterwards. This is primarily useful for testing.
37+
38+
:param \**kwargs: the feature flags to set for the duration of the ``with:``
39+
block. The argument names must match attributes of this object.
40+
41+
.. code-block: python
42+
43+
with FEATURE_FLAGS.set_temporarily(validate_properties_on_set=True):
44+
my_thing.positive_int = -10 # Raises an exception
45+
46+
"""
47+
values_before = {k: getattr(self, k) for k in kwargs.keys()}
48+
try:
49+
for k, v in kwargs.items():
50+
setattr(self, k, v)
51+
yield
52+
finally:
53+
for k, v in values_before.items():
54+
setattr(self, k, v)
55+
56+
57+
FEATURE_FLAGS = LabThingsFeatureFlags()
58+
r"""This module-level object allows features of LabThings to be controlled.
59+
60+
See the documentation for the class `.LabThingsFeatureFlags` for details of the
61+
flags and what they do. More information is available in :ref:`optional_features`\ .
62+
"""

src/labthings_fastapi/properties.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class attribute. Documentation is in strings immediately following the
7272
with_config,
7373
)
7474

75+
from .feature_flags import FEATURE_FLAGS
7576
from .thing_description import type_to_dataschema
7677
from .thing_description._model import (
7778
DataSchema,
@@ -734,11 +735,19 @@ def __set__(
734735
735736
This sets the property's value, and notifies any observers.
736737
738+
If property validation is enabled by `.FEATURE_FLAGS.validate_properties_on_set`
739+
this will validate the value against the property's model, and an error
740+
will be raised if the value is not valid.
741+
737742
:param obj: the `.Thing` to which we are attached.
738743
:param value: the new value for the property.
739744
:param emit_changed_event: whether to emit a changed event.
740745
"""
741-
obj.__dict__[self.name] = value
746+
if FEATURE_FLAGS.validate_properties_on_set:
747+
property_info = self.descriptor_info(obj)
748+
obj.__dict__[self.name] = property_info.validate(value)
749+
else:
750+
obj.__dict__[self.name] = value
742751
if emit_changed_event:
743752
self.emit_changed_event(obj, value)
744753

@@ -974,13 +983,21 @@ def instance_get(self, obj: Owner) -> Value:
974983
def __set__(self, obj: Owner, value: Value) -> None:
975984
"""Set the value of the property.
976985
986+
If property validation is enabled by `.FEATURE_FLAGS.validate_properties_on_set`
987+
this will validate the value against the property's model, and an error
988+
will be raised if the value is not valid.
989+
977990
:param obj: the `.Thing` on which the attribute is accessed.
978991
:param value: the value of the property.
979992
980993
:raises ReadOnlyPropertyError: if the property cannot be set.
981994
"""
982995
if self.fset is None:
983996
raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.")
997+
if FEATURE_FLAGS.validate_properties_on_set:
998+
property_info = self.descriptor_info(obj)
999+
value = property_info.validate(value)
1000+
9841001
self.fset(obj, value)
9851002

9861003
@builtins.property

tests/test_feature_flags.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Test the feature flags mechanism.
2+
3+
Specific feature flags should be tested by the test code for the relevant feature. This
4+
test module checks that `set_temporarily` works as expected.
5+
"""
6+
7+
import pytest
8+
9+
import labthings_fastapi as lt
10+
11+
12+
@pytest.mark.parametrize("value", [True, False])
13+
def test_set_temporarily(value):
14+
"""Test values may be set and reset."""
15+
value_before = lt.FEATURE_FLAGS.validate_properties_on_set
16+
17+
with lt.FEATURE_FLAGS.set_temporarily(validate_properties_on_set=value):
18+
assert lt.FEATURE_FLAGS.validate_properties_on_set == value
19+
20+
with lt.FEATURE_FLAGS.set_temporarily(validate_properties_on_set=not value):
21+
assert lt.FEATURE_FLAGS.validate_properties_on_set != value
22+
23+
assert lt.FEATURE_FLAGS.validate_properties_on_set == value
24+
25+
assert lt.FEATURE_FLAGS.validate_properties_on_set == value_before
26+
27+
28+
def test_set_bad_setting():
29+
"""Test for errors when bad flags are used."""
30+
with pytest.raises(AttributeError):
31+
with lt.FEATURE_FLAGS.set_temporarily(bogus_name=True):
32+
pass
33+
with pytest.raises(AttributeError):
34+
lt.FEATURE_FLAGS.bogus_name = True
35+
36+
37+
@pytest.mark.parametrize("value", [True, False])
38+
def test_set_temporarily_with_exception(value):
39+
"""Test that flags are reset even if an exception occurs inside the block."""
40+
value_before = lt.FEATURE_FLAGS.validate_properties_on_set
41+
42+
with pytest.raises(RuntimeError, match="Simulated crash"):
43+
with lt.FEATURE_FLAGS.set_temporarily(validate_properties_on_set=value):
44+
assert lt.FEATURE_FLAGS.validate_properties_on_set == value
45+
raise RuntimeError("Simulated crash")
46+
47+
# The flag should be restored despite the exception
48+
assert lt.FEATURE_FLAGS.validate_properties_on_set == value_before

tests/test_properties.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ServerNotRunningError,
1515
UnsupportedConstraintError,
1616
)
17+
from labthings_fastapi.feature_flags import FEATURE_FLAGS
1718
from labthings_fastapi.properties import BaseProperty, PropertyInfo
1819
from labthings_fastapi.testing import create_thing_without_server
1920
from .temp_client import poll_task
@@ -378,21 +379,51 @@ def test_setting_without_event_loop():
378379

379380

380381
@pytest.mark.parametrize("prop_info", CONSTRAINED_PROPS)
381-
def test_constrained_properties(prop_info):
382-
"""Test that constraints on property values generate correct models."""
382+
def test_constrained_properties(prop_info, mocker):
383+
"""Test that constraints on property values generate correct models.
384+
385+
This also tests the `validate` method and checks validation happens
386+
on assignment to the property in Python. Further checks over http
387+
are made in later tests.
388+
"""
383389
prop = prop_info.prop
384390
assert prop.value_type is prop_info.value_type
385391
m = prop.model
386392
assert issubclass(m, RootModel)
393+
mock_thing = mocker.Mock(spec=PropertyTestThing)
394+
mock_thing._thing_server_interface = mocker.Mock()
395+
descriptorinfo = prop.descriptor_info(mock_thing)
396+
assert isinstance(descriptorinfo, PropertyInfo)
387397
for ann in prop_info.constraints:
388398
assert any(meta == ann for meta in m.model_fields["root"].metadata)
389399
for valid in prop_info.valid_values:
400+
# Check the model can be created
390401
instance = m(root=valid)
402+
# Check the value passes through the model
391403
validated = instance.model_dump()
392404
assert validated == valid or validated is valid # `is` for NaN
405+
# Check the descriptorinfo object also validates
406+
# (this is what we get from thing.properties["name"])
407+
validated = descriptorinfo.validate(valid)
408+
assert validated == valid or validated is valid # `is` for NaN
409+
# Check that assignment works
410+
prop.__set__(mock_thing, valid)
411+
validated = prop.__get__(mock_thing)
412+
assert validated == valid or validated is valid # `is` for NaN
413+
393414
for invalid in prop_info.invalid_values:
394415
with pytest.raises(ValidationError):
395416
_ = m(root=invalid)
417+
with pytest.raises(ValidationError):
418+
descriptorinfo.validate(invalid)
419+
# We check that validation on __set__ only applies if it's enabled.
420+
with FEATURE_FLAGS.set_temporarily(validate_properties_on_set=True):
421+
with pytest.raises(ValidationError):
422+
prop.__set__(mock_thing, invalid)
423+
with FEATURE_FLAGS.set_temporarily(validate_properties_on_set=False):
424+
prop.__set__(mock_thing, invalid)
425+
not_validated = prop.__get__(mock_thing)
426+
assert not_validated == invalid or not_validated is invalid
396427

397428

398429
def convert_inf_nan(value):

tests/test_property.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import pydantic
2121
import pytest
2222
from labthings_fastapi import properties
23+
from labthings_fastapi.feature_flags import FEATURE_FLAGS
2324
from labthings_fastapi.properties import (
2425
BaseProperty,
2526
DataProperty,
@@ -375,8 +376,9 @@ class BadIntModel(pydantic.BaseModel):
375376
# Check that a broken `_model` raises the right error
376377
# See above for where we manually set badprop._model to something that's
377378
# not a rootmodel.
378-
example.badprop = 3
379-
assert example.badprop == 3
379+
with FEATURE_FLAGS.set_temporarily(validate_properties_on_set=True):
380+
with pytest.raises(TypeError):
381+
example.badprop = 3 # Validation will fail here because of the bad model.
380382
with pytest.raises(TypeError):
381383
_ = example.properties["badprop"].model_instance
382384
with pytest.raises(TypeError):
@@ -401,12 +403,24 @@ class BadIntModel(pydantic.BaseModel):
401403
# Check validation for a model
402404
modelprop = example.properties["modelprop"]
403405
assert modelprop.validate(MyModel(a=3, b="four")) == MyModel(a=3, b="four")
406+
# Check that a valid model passes through unchanged: this should indicate that
407+
# we're not unnecessarily re-validating already-valid models.
404408
m = MyModel(a=3, b="four")
405409
assert modelprop.validate(m) is m
406410
assert modelprop.validate({"a": 5, "b": "six"}) == MyModel(a=5, b="six")
407411
for invalid in [{"c": 5}, (4, "f"), None]:
408412
with pytest.raises(pydantic.ValidationError):
409413
modelprop.validate(invalid)
414+
# Check that an invalid model doesn't get re-validated. This is intended behaviour:
415+
# it is another test that we're not unnecessarily re-validating a model that
416+
# should already have been validated when it was created.
417+
# Creating models with `model_construct` intentionally allows invalid models:
418+
# if this is used downstream, the downstream code should accept responsibility!
419+
bad_m = MyModel.model_construct(a="not an int", b=6)
420+
assert modelprop.validate(bad_m) is bad_m
421+
with pytest.raises(pydantic.ValidationError):
422+
# Check that passing the same data in as a dict fails validation.
423+
modelprop.validate(bad_m.model_dump())
410424

411425
# Check again for an odd rootmodel
412426
rootmodelprop = example.properties["rootmodelprop"]
@@ -419,6 +433,11 @@ class BadIntModel(pydantic.BaseModel):
419433
for invalid in ["seven", {"root": None}, 14.5, pydantic.RootModel[int](root=0)]:
420434
with pytest.raises(pydantic.ValidationError):
421435
modelprop.validate(invalid)
436+
# Tty constructing a model with an invalid root value, skipping validation
437+
invalid_model = rootmodelprop.model.model_construct(invalid)
438+
# The RootModel gets re-validated, in contrast to the BaseModel above.
439+
with pytest.raises(pydantic.ValidationError):
440+
assert modelprop.validate(invalid_model) == invalid
422441

423442

424443
def test_readonly_metadata():

0 commit comments

Comments
 (0)