Skip to content

Commit f61cba9

Browse files
authored
Merge pull request #269 from labthings/generic-type-settings
Make loading settings robust to subscripted generics.
2 parents 31c06ec + cb06e57 commit f61cba9

File tree

5 files changed

+228
-69
lines changed

5 files changed

+228
-69
lines changed

src/labthings_fastapi/properties.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class attribute. Documentation is in strings immediately following the
6262
from weakref import WeakSet
6363

6464
from fastapi import Body, FastAPI
65-
from pydantic import BaseModel, ConfigDict, RootModel, create_model
65+
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, create_model
6666

6767
from .thing_description import type_to_dataschema
6868
from .thing_description._model import (
@@ -71,7 +71,11 @@ class attribute. Documentation is in strings immediately following the
7171
PropertyAffordance,
7272
PropertyOp,
7373
)
74-
from .utilities import labthings_data, wrap_plain_types_in_rootmodel
74+
from .utilities import (
75+
LabThingsRootModelWrapper,
76+
labthings_data,
77+
wrap_plain_types_in_rootmodel,
78+
)
7579
from .utilities.introspection import return_type
7680
from .base_descriptor import (
7781
DescriptorInfoCollection,
@@ -859,27 +863,45 @@ def model_instance(self) -> BaseModel: # noqa: DOC201
859863
raise TypeError(msg)
860864
return cls(root=value)
861865

862-
def model_to_value(self, value: BaseModel) -> Value:
863-
r"""Convert a model to a value for this property.
866+
def validate(self, value: Any) -> Value:
867+
"""Use the validation logic in `self.model`.
868+
869+
This method should accept anything that `pydantic` can convert to the
870+
right type, and return a correctly-typed value. It also enforces any
871+
constraints that were set on the property.
864872
865-
Even properties with plain types are sometimes converted to or from a
866-
`pydantic.BaseModel` to allow conversion to/from JSON. This is a convenience
867-
method that accepts a model (which should be an instance of ``self.model``\ )
868-
and unwraps it when necessary to get the plain Python value.
873+
:param value: The new value, in any form acceptable to the property's
874+
model. Usually this means you can use either the correct type, or
875+
the value as loaded from JSON.
869876
870-
:param value: A `.BaseModel` instance to convert.
871-
:return: the value, with `.RootModel` unwrapped so it matches the descriptor's
872-
type.
873-
:raises TypeError: if the supplied value cannot be converted to the right type.
877+
:return: the new value, with the correct type.
878+
879+
:raises ValidationError: if the supplied value can't be loaded by
880+
the property's model. This is the exception raised by ``model_validate``
881+
:raises TypeError: if the property has a ``model`` that's inconsistent
882+
with its value type. This should never happen.
874883
"""
875-
if isinstance(value, self.value_type):
876-
return value
877-
elif isinstance(value, RootModel):
878-
root = value.root
879-
if isinstance(root, self.value_type):
880-
return root
881-
msg = f"Model {value} isn't {self.value_type} or a RootModel wrapping it."
882-
raise TypeError(msg)
884+
try:
885+
if issubclass(self.model, LabThingsRootModelWrapper):
886+
# If a plain type has been wrapped in a RootModel, use that to validate
887+
# and then set the property to the root value.
888+
model = self.model.model_validate(value)
889+
return model.root
890+
891+
if issubclass(self.value_type, BaseModel) and self.model is self.value_type:
892+
# If there's no RootModel wrapper, the value was defined in code as a
893+
# Pydantic model. This means `value_type` and `model` should both
894+
# be that same class.
895+
return self.value_type.model_validate(value)
896+
897+
# This should be unreachable, because `model` is a
898+
# `LabThingsRootModelWrapper` wrapping the value type, or the value type
899+
# should be a BaseModel.
900+
msg = f"Property {self.name} has an inconsistent model. This is "
901+
msg += f"most likely a LabThings bug. {self.model=}, {self.value_type=}"
902+
raise TypeError(msg)
903+
except ValidationError:
904+
raise # This is needed for flake8 to be happy with the docstring
883905

884906

885907
class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]):
@@ -1139,13 +1161,6 @@ def set_without_emit(self, value: Value) -> None:
11391161
obj = self.owning_object_or_error()
11401162
self.get_descriptor().set_without_emit(obj, value)
11411163

1142-
def set_without_emit_from_model(self, value: BaseModel) -> None:
1143-
"""Set the value from a model instance, unwrapping RootModels as needed.
1144-
1145-
:param value: the model to extract the value from.
1146-
"""
1147-
self.set_without_emit(self.model_to_value(value))
1148-
11491164

11501165
class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Owner]):
11511166
"""Access to metadata on all the properties of a `.Thing` instance or subclass.

src/labthings_fastapi/thing.py

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,50 @@ def thing_description(request: Request) -> ThingDescription:
188188
async def websocket(ws: WebSocket) -> None:
189189
await websocket_endpoint(self, ws)
190190

191+
def _read_settings_file(self) -> Mapping[str, Any] | None:
192+
"""Read the settings file and return a mapping of saved settings or None.
193+
194+
This function handles reading the settings from the disk. It is designed
195+
to be called by `load_settings`. Any exceptions caused by file handling or
196+
file corruption are caught and logged as warnings.
197+
198+
:return: A Mapping of setting name to setting value, or None if no settings
199+
could be read from file.
200+
"""
201+
setting_storage_path = self._thing_server_interface.settings_file_path
202+
thing_name = type(self).__name__
203+
if not os.path.exists(setting_storage_path):
204+
# If the settings file doesn't exist, we have nothing to do - the settings
205+
# are already initialised to their default values.
206+
return None
207+
208+
# Load the settings.
209+
try:
210+
with open(setting_storage_path, "r", encoding="utf-8") as file_obj:
211+
settings = json.load(file_obj)
212+
except (FileNotFoundError, PermissionError, JSONDecodeError):
213+
# Note that if the settings file is missing, we should already have
214+
# returned before attempting to load settings.
215+
self.logger.warning(
216+
"Error loading settings for %s from %s, could not load a JSON "
217+
"object. Settings for this Thing will be reset to default.",
218+
thing_name,
219+
setting_storage_path,
220+
)
221+
return None
222+
223+
if not isinstance(settings, Mapping):
224+
self.logger.warning(
225+
"Error loading settings for %s from %s. The file does not contain a "
226+
"Mapping",
227+
thing_name,
228+
setting_storage_path,
229+
)
230+
return None
231+
232+
# The settings are loaded and are a Mapping. Return them.
233+
return settings
234+
191235
def load_settings(self) -> None:
192236
"""Load settings from json.
193237
@@ -200,32 +244,20 @@ def load_settings(self) -> None:
200244
Note that no notifications will be triggered when the settings are set,
201245
so if action is needed (e.g. updating hardware with the loaded settings)
202246
it should be taken in ``__enter__``.
203-
204-
:raises TypeError: if the JSON file does not contain a dictionary. This is
205-
handled internally and logged, so the exception doesn't propagate
206-
outside of the function.
207247
"""
208-
setting_storage_path = self._thing_server_interface.settings_file_path
209-
thing_name = type(self).__name__
210-
if not os.path.exists(setting_storage_path):
211-
# If the settings file doesn't exist, we have nothing to do - the settings
212-
# are already initialised to their default values.
248+
settings = self._read_settings_file()
249+
if settings is None:
250+
# Return if no settings can be loaded from file.
213251
return
214252

215253
# Stop recursion by not allowing settings to be saved as we're reading them.
216254
self._disable_saving_settings = True
217-
218255
try:
219-
with open(setting_storage_path, "r", encoding="utf-8") as file_obj:
220-
settings = json.load(file_obj)
221-
if not isinstance(settings, Mapping):
222-
raise TypeError("The settings file must be a JSON object.")
223256
for name, value in settings.items():
224257
try:
225258
setting = self.settings[name]
226259
# Load the key from the JSON file using the setting's model
227-
model = setting.model.model_validate(value)
228-
setting.set_without_emit_from_model(model)
260+
setting.set_without_emit(setting.validate(value))
229261
except ValidationError:
230262
self.logger.warning(
231263
f"Could not load setting {name} from settings file "
@@ -237,19 +269,6 @@ def load_settings(self) -> None:
237269
f"An extra key {name} was found in the settings file. "
238270
"It will be deleted the next time settings are saved."
239271
)
240-
except (
241-
FileNotFoundError,
242-
JSONDecodeError,
243-
PermissionError,
244-
TypeError,
245-
):
246-
# Note that if the settings file is missing, we should already have returned
247-
# before attempting to load settings.
248-
self.logger.warning(
249-
"Error loading settings for %s. "
250-
"Settings for this Thing will be reset to default.",
251-
thing_name,
252-
)
253272
finally:
254273
self._disable_saving_settings = False
255274

src/labthings_fastapi/utilities/__init__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44
from collections.abc import Mapping
5-
from typing import Any, Dict, Iterable, TYPE_CHECKING, Optional
5+
from typing import Any, Dict, Generic, Iterable, TYPE_CHECKING, Optional, TypeVar
66
from weakref import WeakSet
77
from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model
88
from pydantic.dataclasses import dataclass
@@ -95,6 +95,21 @@ def labthings_data(obj: Thing) -> LabThingsObjectData:
9595
return obj.__dict__[LABTHINGS_DICT_KEY]
9696

9797

98+
WrappedT = TypeVar("WrappedT")
99+
100+
101+
class LabThingsRootModelWrapper(RootModel[WrappedT], Generic[WrappedT]):
102+
"""A RootModel subclass for automatically-wrapped types.
103+
104+
There are several places where LabThings needs a model, but may only
105+
have a plain Python type. This subclass indicates to LabThings that
106+
a type has been automatically wrapped, and will need to be unwrapped
107+
in order for the value to have the correct type.
108+
109+
It has no additional functionality.
110+
"""
111+
112+
98113
def wrap_plain_types_in_rootmodel(
99114
model: type, constraints: Mapping[str, Any] | None = None
100115
) -> type[BaseModel]:
@@ -131,7 +146,7 @@ def wrap_plain_types_in_rootmodel(
131146
return create_model(
132147
f"{model!r}",
133148
root=(model, Field(**constraints)),
134-
__base__=RootModel,
149+
__base__=LabThingsRootModelWrapper,
135150
)
136151
except SchemaError as e:
137152
for error in e.errors():

tests/test_property.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,34 @@ def prop(self) -> bool:
304304
def test_propertyinfo(mocker):
305305
"""Test out the PropertyInfo class."""
306306

307+
class MyModel(pydantic.BaseModel):
308+
a: int
309+
b: str
310+
307311
class Example(lt.Thing):
308312
intprop: int = lt.property(default=0)
309313
"""A normal, simple, integer property."""
310314

315+
positive: int = lt.property(default=0, gt=0)
316+
"""A positive integer property."""
317+
311318
badprop: int = lt.property(default=1)
312319
"""An integer property that I will break later."""
313320

321+
tupleprop: tuple[int, str] = lt.property(default=(42, "the answer"))
322+
"""A tuple property, to check subscripted generics work."""
323+
324+
modelprop: MyModel = lt.property(default_factory=lambda: MyModel(a=1, b="two"))
325+
"""A property typed as a model."""
326+
327+
rootmodelprop: pydantic.RootModel[int | None] = lt.property(
328+
default_factory=lambda: pydantic.RootModel[int | None](root=None)
329+
)
330+
"""A very verbosely defined optional integer.
331+
332+
This tests a model that's also a subscripted generic.
333+
"""
334+
314335
# We will break `badprop` by setting its model to something that's
315336
# neither the type nor a rootmodel.
316337
badprop = Example.badprop
@@ -331,11 +352,15 @@ class BadIntModel(pydantic.BaseModel):
331352
assert isinstance(model, pydantic.RootModel)
332353
assert model.root == 15
333354

334-
# Check we can unwrap a RootModel correctly
335-
IntModel = example.properties["intprop"].model
336-
assert example.properties["intprop"].model_to_value(IntModel(root=17)) == 17
337-
with pytest.raises(TypeError):
338-
example.properties["intprop"].model_to_value(BadIntModel(root=17))
355+
# Check we can validate properly
356+
intprop = example.properties["intprop"]
357+
assert intprop.validate(15) == 15 # integers pass straight through
358+
assert intprop.validate(-15) == -15
359+
# A RootModel instance ought still to validate
360+
assert intprop.validate(intprop.model(root=42)) == 42
361+
# A wrong model won't, though.
362+
with pytest.raises(pydantic.ValidationError):
363+
intprop.validate(BadIntModel(root=42))
339364

340365
# Check that a broken `_model` raises the right error
341366
# See above for where we manually set badprop._model to something that's
@@ -344,6 +369,46 @@ class BadIntModel(pydantic.BaseModel):
344369
assert example.badprop == 3
345370
with pytest.raises(TypeError):
346371
_ = example.properties["badprop"].model_instance
372+
with pytest.raises(TypeError):
373+
# The value is fine, but the model has been set to an invalid type.
374+
# This error shouldn't be seen in production.
375+
example.properties["badprop"].validate(0)
376+
377+
# Check validation applies constraints
378+
positive = example.properties["positive"]
379+
assert positive.validate(42) == 42
380+
with pytest.raises(pydantic.ValidationError):
381+
positive.validate(0)
382+
383+
# Check validation works for subscripted generics
384+
tupleprop = example.properties["tupleprop"]
385+
assert tupleprop.validate((1, "two")) == (1, "two")
386+
387+
for val in [0, "str", ("str", 0)]:
388+
with pytest.raises(pydantic.ValidationError):
389+
tupleprop.validate(val)
390+
391+
# Check validation for a model
392+
modelprop = example.properties["modelprop"]
393+
assert modelprop.validate(MyModel(a=3, b="four")) == MyModel(a=3, b="four")
394+
m = MyModel(a=3, b="four")
395+
assert modelprop.validate(m) is m
396+
assert modelprop.validate({"a": 5, "b": "six"}) == MyModel(a=5, b="six")
397+
for invalid in [{"c": 5}, (4, "f"), None]:
398+
with pytest.raises(pydantic.ValidationError):
399+
modelprop.validate(invalid)
400+
401+
# Check again for an odd rootmodel
402+
rootmodelprop = example.properties["rootmodelprop"]
403+
m = rootmodelprop.validate(42)
404+
assert isinstance(m, pydantic.RootModel)
405+
assert m.root == 42
406+
assert m == pydantic.RootModel[int | None](root=42)
407+
assert rootmodelprop.validate(m) is m # RootModel passes through
408+
assert rootmodelprop.validate(None).root is None
409+
for invalid in ["seven", {"root": None}, 14.5, pydantic.RootModel[int](root=0)]:
410+
with pytest.raises(pydantic.ValidationError):
411+
modelprop.validate(invalid)
347412

348413

349414
def test_readonly_metadata():
@@ -379,6 +444,7 @@ def _set_funcprop(self, val: int) -> None:
379444
example = create_thing_without_server(Example)
380445

381446
td = example.thing_description()
447+
assert td.properties is not None # This is mostly for type checking
382448

383449
# Check read-write properties are not read-only
384450
for name in ["prop", "funcprop"]:

0 commit comments

Comments
 (0)