Skip to content

Commit 31c06ec

Browse files
authored
Merge pull request #262 from labthings/readonly-metadata
Propagate read-only metadata to the Thing Description.
2 parents e34f93e + 24efaba commit 31c06ec

4 files changed

Lines changed: 183 additions & 23 deletions

File tree

src/labthings_fastapi/client/__init__.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def set_property(self, path: str, value: Any) -> None:
180180
):
181181
err_msg = detail[0].get("msg", "Unknown error")
182182

183-
raise ClientPropertyError(f"Failed to get property {path}: {err_msg}")
183+
raise ClientPropertyError(f"Failed to set property {path}: {err_msg}")
184184

185185
def invoke_action(self, path: str, **kwargs: Any) -> Any:
186186
r"""Invoke an action on the Thing.
@@ -358,18 +358,34 @@ def __get__(
358358
if obj is None:
359359
return self
360360
return obj.get_property(self.name)
361+
else:
362+
363+
def __get__(
364+
self: PropertyClientDescriptor,
365+
obj: Optional[ThingClient] = None,
366+
_objtype: Optional[type[ThingClient]] = None,
367+
) -> Any:
368+
raise ClientPropertyError("This property may not be read.")
369+
370+
__get__.__annotations__["return"] = model
371+
P.__get__ = __get__ # type: ignore[attr-defined]
361372

362-
__get__.__annotations__["return"] = model
363-
P.__get__ = __get__ # type: ignore[attr-defined]
373+
# Set __set__ method based on whether writable
364374
if writeable:
365375

366376
def __set__(
367377
self: PropertyClientDescriptor, obj: ThingClient, value: Any
368378
) -> None:
369379
obj.set_property(self.name, value)
380+
else:
381+
382+
def __set__(
383+
self: PropertyClientDescriptor, obj: ThingClient, value: Any
384+
) -> None:
385+
raise ClientPropertyError("This property may not be set.")
370386

371-
__set__.__annotations__["value"] = model
372-
P.__set__ = __set__ # type: ignore[attr-defined]
387+
__set__.__annotations__["value"] = model
388+
P.__set__ = __set__ # type: ignore[attr-defined]
373389
if description:
374390
P.__doc__ = description
375391
return P()

src/labthings_fastapi/properties.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,8 @@ def property_affordance(
467467
title=self.title,
468468
forms=forms,
469469
description=self.description,
470+
readOnly=self.readonly,
471+
writeOnly=False, # write-only properties are not yet supported
470472
)
471473
# We merge the data schema with the property affordance (which subclasses the
472474
# DataSchema model) with the affordance second so its values take priority.

tests/test_property.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,49 @@ class BadIntModel(pydantic.BaseModel):
344344
assert example.badprop == 3
345345
with pytest.raises(TypeError):
346346
_ = example.properties["badprop"].model_instance
347+
348+
349+
def test_readonly_metadata():
350+
"""Check read-only data propagates to the Thing Description."""
351+
352+
class Example(lt.Thing):
353+
prop: int = lt.property(default=0)
354+
ro_property: int = lt.property(default=0, readonly=True)
355+
356+
@lt.property
357+
def ro_functional_property(self) -> int:
358+
"""This property should be read-only as there's no setter."""
359+
return 42
360+
361+
@lt.property
362+
def ro_functional_property_with_setter(self) -> int:
363+
return 42
364+
365+
@ro_functional_property_with_setter.setter
366+
def _set_ro_functional_property_with_setter(self, val: int) -> None:
367+
pass
368+
369+
ro_functional_property_with_setter.readonly = True
370+
371+
@lt.property
372+
def funcprop(self) -> int:
373+
return 42
374+
375+
@funcprop.setter
376+
def _set_funcprop(self, val: int) -> None:
377+
pass
378+
379+
example = create_thing_without_server(Example)
380+
381+
td = example.thing_description()
382+
383+
# Check read-write properties are not read-only
384+
for name in ["prop", "funcprop"]:
385+
assert td.properties[name].readOnly is False
386+
387+
for name in [
388+
"ro_property",
389+
"ro_functional_property",
390+
"ro_functional_property_with_setter",
391+
]:
392+
assert td.properties[name].readOnly is True

tests/test_thing_client.py

Lines changed: 114 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
"""Test that Thing Client's can call actions and read properties."""
1+
"""Test that Thing Clients can call actions and read properties."""
22

33
import re
44

55
import pytest
66
import labthings_fastapi as lt
77
from fastapi.testclient import TestClient
88

9+
from labthings_fastapi.exceptions import ClientPropertyError, FailedToInvokeActionError
10+
911

1012
class ThingToTest(lt.Thing):
1113
"""A thing to be tested by using a ThingClient."""
@@ -59,94 +61,188 @@ def throw_value_error(self) -> None:
5961

6062

6163
@pytest.fixture
62-
def thing_client():
63-
"""Yield a test client connected to a ThingServer."""
64+
def thing_client_and_thing():
65+
"""Yield a test client connected to a ThingServer and the Thing itself."""
6466
server = lt.ThingServer({"test_thing": ThingToTest})
6567
with TestClient(server.app) as client:
66-
yield lt.ThingClient.from_url("/test_thing/", client=client)
68+
thing_client = lt.ThingClient.from_url("/test_thing/", client=client)
69+
thing = server.things["test_thing"]
70+
yield thing_client, thing
71+
72+
73+
@pytest.fixture
74+
def thing_client(thing_client_and_thing):
75+
"""Yield a test client connected to a ThingServer."""
76+
return thing_client_and_thing[0]
6777

6878

69-
def test_reading_and_setting_properties(thing_client):
79+
def test_reading_and_setting_properties(thing_client_and_thing):
7080
"""Test reading and setting properties."""
81+
thing_client, thing = thing_client_and_thing
82+
83+
# Read the properties from the thing
7184
assert thing_client.int_prop == 1
7285
assert thing_client.float_prop == 0.1
7386
assert thing_client.str_prop == "foo"
7487

88+
# Update via thing client and check they change on the server and in the client
7589
thing_client.int_prop = 2
7690
thing_client.float_prop = 0.2
7791
thing_client.str_prop = "foo2"
7892

93+
# Check the server updated
94+
assert thing.int_prop == 2
95+
assert thing.float_prop == 0.2
96+
assert thing.str_prop == "foo2"
97+
# Check the client updated
7998
assert thing_client.int_prop == 2
8099
assert thing_client.float_prop == 0.2
81100
assert thing_client.str_prop == "foo2"
82101

102+
# Update them on the server side and read them again
103+
thing.int_prop = 3
104+
thing.float_prop = 0.3
105+
thing.str_prop = "foo3"
106+
assert thing_client.int_prop == 3
107+
assert thing_client.float_prop == 0.3
108+
assert thing_client.str_prop == "foo3"
109+
83110
# Set a property that doesn't exist.
84111
err = "Failed to get property foobar: Not Found"
85-
with pytest.raises(lt.exceptions.ClientPropertyError, match=err):
112+
with pytest.raises(ClientPropertyError, match=err):
86113
thing_client.get_property("foobar")
87114

88115
# Set a property with bad data type.
89116
err = (
90-
"Failed to get property int_prop: Input should be a valid integer, unable to "
117+
"Failed to set property int_prop: Input should be a valid integer, unable to "
91118
"parse string as an integer"
92119
)
93-
with pytest.raises(lt.exceptions.ClientPropertyError, match=err):
120+
with pytest.raises(ClientPropertyError, match=err):
94121
thing_client.int_prop = "Bad value!"
95122

96123

97-
def test_reading_and_not_setting_read_only_properties(thing_client):
124+
def test_reading_and_not_setting_read_only_properties(thing_client_and_thing):
98125
"""Test reading read_only properties, but failing to set."""
126+
thing_client, thing = thing_client_and_thing
99127
assert thing_client.int_prop_read_only == 1
100128
assert thing_client.float_prop_read_only == 0.1
101129
assert thing_client.str_prop_read_only == "foo"
102130

103-
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
131+
with pytest.raises(ClientPropertyError, match="may not be set"):
104132
thing_client.int_prop_read_only = 2
105-
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
133+
with pytest.raises(ClientPropertyError, match="may not be set"):
106134
thing_client.float_prop_read_only = 0.2
107-
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
135+
with pytest.raises(ClientPropertyError, match="may not be set"):
108136
thing_client.str_prop_read_only = "foo2"
109137

110138
assert thing_client.int_prop_read_only == 1
111139
assert thing_client.float_prop_read_only == 0.1
112140
assert thing_client.str_prop_read_only == "foo"
113141

114142

115-
def test_call_action(thing_client):
143+
def test_property_descriptor_errors(mocker):
144+
"""This checks that read/write-only properties raise an error when written/read.
145+
146+
Write only properties are not yet supported on the server side, so it's done with a
147+
mocked up Thing Description.
148+
"""
149+
thing_description = {
150+
"title": "Example",
151+
"properties": {
152+
"readwrite": {
153+
"title": "test",
154+
"writeOnly": False,
155+
"readOnly": False,
156+
"type": "integer",
157+
"forms": [],
158+
},
159+
"readonly": {
160+
"title": "test",
161+
"writeOnly": False,
162+
"readOnly": True,
163+
"type": "integer",
164+
"forms": [],
165+
},
166+
"writeonly": {
167+
"title": "test",
168+
"writeOnly": True,
169+
"readOnly": False,
170+
"type": "integer",
171+
"forms": [],
172+
},
173+
},
174+
"actions": {},
175+
"base": None,
176+
"securityDefinitions": {"no_security": {}},
177+
"security": "no_security",
178+
}
179+
180+
# Create a client object
181+
MyClient = lt.ThingClient.subclass_from_td(thing_description)
182+
client = MyClient("/", mocker.Mock())
183+
# Mock the underlying get/set functions so we don't need a server
184+
mocker.patch.object(client, "get_property")
185+
client.get_property.return_value = 42
186+
mocker.patch.object(client, "set_property")
187+
188+
# Check which properties we can read
189+
assert client.readwrite == 42
190+
assert client.readonly == 42
191+
with pytest.raises(ClientPropertyError, match="may not be read"):
192+
_ = client.writeonly
193+
assert client.get_property.call_count == 2
194+
195+
# The same check for writing
196+
client.readwrite = 0
197+
client.writeonly = 0
198+
with pytest.raises(ClientPropertyError, match="may not be set"):
199+
_ = client.readonly = 0
200+
assert client.set_property.call_count == 2
201+
202+
203+
def test_call_action(thing_client_and_thing):
116204
"""Test calling an action."""
205+
thing_client, thing = thing_client_and_thing
117206
assert thing_client.int_prop == 1
118207
thing_client.increment()
119208
assert thing_client.int_prop == 2
209+
assert thing.int_prop == 2
120210

121211

122-
def test_call_action_with_return(thing_client):
212+
def test_call_action_with_return(thing_client_and_thing):
123213
"""Test calling an action with a return."""
214+
thing_client, thing = thing_client_and_thing
124215
assert thing_client.int_prop == 1
125216
new_value = thing_client.increment_and_return()
126217
assert new_value == 2
127218
assert thing_client.int_prop == 2
219+
assert thing.int_prop == 2
128220

129221

130-
def test_call_action_with_args(thing_client):
222+
def test_call_action_with_args(thing_client_and_thing):
131223
"""Test calling an action."""
224+
thing_client, thing = thing_client_and_thing
132225
assert thing_client.int_prop == 1
133226
thing_client.increment_by_input(value=5)
134227
assert thing_client.int_prop == 6
228+
assert thing.int_prop == 6
135229

136230

137-
def test_call_action_with_args_and_return(thing_client):
231+
def test_call_action_with_args_and_return(thing_client_and_thing):
138232
"""Test calling an action with a return."""
233+
thing_client, thing = thing_client_and_thing
139234
assert thing_client.int_prop == 1
140235
new_value = thing_client.increment_by_input_and_return(value=5)
141236
assert new_value == 6
142237
assert thing_client.int_prop == 6
238+
assert thing.int_prop == 6
143239

144240

145241
def test_call_action_wrong_arg(thing_client):
146242
"""Test calling an action with wrong argument."""
147243
err = "Error when invoking action increment_by_input: 'value' - Field required"
148244

149-
with pytest.raises(lt.exceptions.FailedToInvokeActionError, match=err):
245+
with pytest.raises(FailedToInvokeActionError, match=err):
150246
thing_client.increment_by_input(input=5)
151247

152248

@@ -156,7 +252,7 @@ def test_call_action_wrong_type(thing_client):
156252
"Error when invoking action increment_by_input: 'value' - Input should be a "
157253
"valid integer, unable to parse string as an integer"
158254
)
159-
with pytest.raises(lt.exceptions.FailedToInvokeActionError, match=err):
255+
with pytest.raises(FailedToInvokeActionError, match=err):
160256
thing_client.increment_by_input(value="foo")
161257

162258

0 commit comments

Comments
 (0)