Skip to content

Commit 5af35ed

Browse files
Add unit tests and docstrings. Also add pathcheck when addign a thing
1 parent e4afc1b commit 5af35ed

File tree

8 files changed

+262
-15
lines changed

8 files changed

+262
-15
lines changed

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ artifacts = ["src/*.json"]
5151
[tool.hatch.build.targets.wheel]
5252
artifacts = ["src/*.json"]
5353

54+
[tool.pytest.ini_options]
55+
addopts = [
56+
"--cov=labthings_fastapi",
57+
"--cov-report=term",
58+
"--cov-report=xml:coverage.xml",
59+
"--cov-report=html:htmlcov",
60+
]
61+
5462
[tool.ruff]
5563
target-version = "py310"
5664

src/labthings_fastapi/descriptors/action.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
from functools import partial
77
import inspect
88
from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional, Literal, overload
9+
from weakref import WeakSet
10+
911
from fastapi import Body, FastAPI, Request, BackgroundTasks
1012
from pydantic import create_model
13+
1114
from ..actions import InvocationModel
1215
from ..dependencies.invocation import CancelHook, InvocationID
1316
from ..dependencies.action_manager import ActionManagerContextDep
@@ -23,9 +26,8 @@
2326
from ..outputs.blob import BlobIOContextDep
2427
from ..thing_description import type_to_dataschema
2528
from ..thing_description.model import ActionAffordance, ActionOp, Form, Union
26-
27-
from weakref import WeakSet
2829
from ..utilities import labthings_data, get_blocking_portal
30+
from ..exceptions import NotConnectedToServerError
2931

3032
if TYPE_CHECKING:
3133
from ..thing import Thing
@@ -129,12 +131,23 @@ def _observers_set(self, obj):
129131
def emit_changed_event(self, obj, status):
130132
"""Notify subscribers that the action status has changed
131133
132-
NB this function **must** be run from a thread, not the event loop.
134+
This function is run from within the `Invocation` thread that
135+
is created when an action is called. It must be run from this thread
136+
as it is communicating with the event loop via an `asyncio` blocking
137+
portal.
138+
139+
:raises NotConnectedToServerError: if the Thing calling the action is not
140+
connected to a server with a running event loop.
133141
"""
134142
try:
135143
runner = get_blocking_portal(obj)
136144
if not runner:
137-
raise RuntimeError("Can't emit without a blocking portal")
145+
thing_name = obj.__class__.__name__
146+
msg = (
147+
f"Cannot emit action changed event. Is {thing_name} connected to "
148+
"a running server?"
149+
)
150+
raise NotConnectedToServerError(msg)
138151
runner.start_task_soon(
139152
self.emit_changed_event_async,
140153
obj,

src/labthings_fastapi/descriptors/property.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
from __future__ import annotations
66
from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional
7+
from weakref import WeakSet
8+
79
from typing_extensions import Self
8-
from labthings_fastapi.utilities.introspection import get_summary, get_docstring
910
from pydantic import BaseModel, RootModel
1011
from fastapi import Body, FastAPI
11-
from weakref import WeakSet
12+
1213
from ..utilities import labthings_data, wrap_plain_types_in_rootmodel
14+
from ..utilities.introspection import get_summary, get_docstring
1315
from ..thing_description.model import PropertyAffordance, Form, DataSchema, PropertyOp
1416
from ..thing_description import type_to_dataschema
17+
from ..exceptions import NotConnectedToServerError
1518

1619

1720
if TYPE_CHECKING:
@@ -118,11 +121,18 @@ def _observers_set(self, obj):
118121
def emit_changed_event(self, obj: Thing, value: Any) -> None:
119122
"""Notify subscribers that the property has changed
120123
121-
NB this function **must** be run from a thread, not the event loop.
124+
This function is run when properties are upadated. It must be run from
125+
within a thread. This could be the `Invocation` thread of a running action, or
126+
the property should be updated over via a client/http. It must be run from a
127+
thread as it is communicating with the event loop via an `asyncio` blocking
128+
portal.
129+
130+
:raises NotConnectedToServerError: if the Thing that is calling the property
131+
update is not connected to a server with a running event loop.
122132
"""
123133
runner = obj._labthings_blocking_portal
124134
if not runner:
125-
raise RuntimeError("Can't emit without a blocking portal")
135+
raise NotConnectedToServerError("Can't emit without a blocking portal")
126136
runner.start_task_soon(
127137
self.emit_changed_event_async,
128138
obj,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""A submodule for custom LabThings-FastAPI Exceptions"""
2+
3+
4+
class NotConnectedToServerError(RuntimeError):
5+
"""The Thing is not connected to a server
6+
7+
This exception is called if a ThingAction is called or
8+
is a ThingProperty is updated on a Thing that is not
9+
connected to a ThingServer. A server connection is needed
10+
to manage asynchronous behaviour.
11+
"""

src/labthings_fastapi/server/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22
from typing import Optional, Sequence, TypeVar
33
import os.path
4+
import re
5+
46
from fastapi import FastAPI, Request
57
from fastapi.middleware.cors import CORSMiddleware
68
from anyio.from_thread import BlockingPortal
@@ -17,6 +19,11 @@
1719
from ..dependencies.thing_server import _thing_servers
1820
from ..outputs.blob import BlobDataManager
1921

22+
# A path should be made up of names separated by / as a path separator.
23+
# Each name should be made of alphanumeric characters, hyphen, or underscore.
24+
# This regex enforces a trailing /
25+
PATH_REGEX = re.compile(r"^/([a-zA-Z0-9\-_]+\/)+$")
26+
2027

2128
class ThingServer:
2229
def __init__(self, settings_folder: Optional[str] = None):
@@ -71,13 +78,26 @@ def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance:
7178
)
7279

7380
def add_thing(self, thing: Thing, path: str):
74-
"""Add a thing to the server"""
81+
"""Add a thing to the server
82+
83+
:param thing: The thing to add to the server.
84+
:param path: the relative path to access the thing on the server. Must only
85+
contain alphanumeric characters, hyphens, or underscores.
86+
"""
87+
# Ensure leading and trailing /
7588
if not path.endswith("/"):
7689
path += "/"
90+
if not path.startswith("/"):
91+
path = "/" + path
92+
if PATH_REGEX.match(path) is None:
93+
msg = (
94+
f"{path} contains unsafe characters. Use only alphanumeric "
95+
"characters, hyphens and underscores"
96+
)
97+
raise ValueError(msg)
7798
if path in self._things:
7899
raise KeyError(f"{path} has already been added to this thing server.")
79100
self._things[path] = thing
80-
# TODO: check for illegal things in `path` - potential security issue.
81101
settings_folder = os.path.join(self.settings_folder, path.lstrip("/"))
82102
os.makedirs(settings_folder, exist_ok=True)
83103
thing.attach_to_server(

tests/test_properties.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from threading import Thread
2+
3+
from pytest import raises
4+
from pydantic import BaseModel
5+
from fastapi.testclient import TestClient
6+
17
from labthings_fastapi.descriptors import ThingProperty
28
from labthings_fastapi.decorators import thing_property, thing_action
39
from labthings_fastapi.thing import Thing
4-
from fastapi.testclient import TestClient
510
from labthings_fastapi.server import ThingServer
6-
from threading import Thread
7-
from pytest import raises
8-
from pydantic import BaseModel
11+
from labthings_fastapi.exceptions import NotConnectedToServerError
912

1013

1114
class TestThing(Thing):
@@ -44,6 +47,12 @@ def toggle_boolprop_from_thread(self):
4447

4548

4649
def test_instantiation_with_type():
50+
"""
51+
Check the internal model (data type) of the ThingSetting descriptor is a BaseModel
52+
53+
To send the data over HTTP LabThings-FastAPI uses Pydantic models to describe data
54+
types.
55+
"""
4756
prop = ThingProperty(bool, False)
4857
assert issubclass(prop.model, BaseModel)
4958

@@ -117,8 +126,11 @@ def test_setting_from_thread():
117126

118127

119128
def test_setting_without_event_loop():
129+
"""Test that an exception is raised if updating a ThingProperty
130+
without connecting the Thing to a running server with an event loop.
131+
"""
120132
# This test may need to change, if we change the intended behaviour
121133
# Currently it should never be necessary to change properties from the
122134
# main thread, so we raise an error if you try to do so
123-
with raises(RuntimeError):
135+
with raises(NotConnectedToServerError):
124136
thing.boolprop = False # Can't call it until the event loop's running

tests/test_settings.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from threading import Thread
2+
import tempfile
3+
import json
4+
import pytest
5+
import os
6+
import logging
7+
8+
from fastapi.testclient import TestClient
9+
10+
from labthings_fastapi.descriptors import ThingSetting
11+
from labthings_fastapi.decorators import thing_setting, thing_action
12+
from labthings_fastapi.thing import Thing
13+
from labthings_fastapi.server import ThingServer
14+
15+
16+
class TestThing(Thing):
17+
boolsetting = ThingSetting(bool, False, description="A boolean setting")
18+
stringsetting = ThingSetting(str, "foo", description="A string setting")
19+
20+
_float = 1.0
21+
22+
@thing_setting
23+
def floatsetting(self) -> float:
24+
return self._float
25+
26+
@floatsetting.setter
27+
def floatsetting(self, value: float):
28+
self._float = value
29+
30+
@thing_action
31+
def toggle_boolsetting(self):
32+
self.boolsetting = not self.boolsetting
33+
34+
@thing_action
35+
def toggle_boolsetting_from_thread(self):
36+
t = Thread(target=self.toggle_boolsetting)
37+
t.start()
38+
39+
40+
def _get_setting_file(server, thingpath):
41+
path = os.path.join(server.settings_folder, thingpath.lstrip("/"), "settings.json")
42+
return os.path.normpath(path)
43+
44+
45+
def _settings_dict(boolsetting=False, floatsetting=1.0, stringsetting="foo"):
46+
"""Return the expected settings dictionary
47+
48+
Parameters can be updated from default values
49+
"""
50+
return {
51+
"boolsetting": boolsetting,
52+
"floatsetting": floatsetting,
53+
"stringsetting": stringsetting,
54+
}
55+
56+
57+
@pytest.fixture
58+
def thing():
59+
return TestThing()
60+
61+
62+
@pytest.fixture
63+
def server():
64+
with tempfile.TemporaryDirectory() as tempdir:
65+
# Yield server rather than return so that the temp directory isn't cleaned up
66+
# until after the test is run
67+
yield ThingServer(settings_folder=tempdir)
68+
69+
70+
def test_setting_available(thing):
71+
"""Check default settings are available before connecting to server"""
72+
assert not thing.boolsetting
73+
assert thing.stringsetting == "foo"
74+
assert thing.floatsetting == 1.0
75+
76+
77+
def test_settings_save(thing, server):
78+
"""Check updated settings are saved to disk"""
79+
setting_file = _get_setting_file(server, "/thing")
80+
server.add_thing(thing, "/thing")
81+
# No setting file created when first added
82+
assert not os.path.isfile(setting_file)
83+
with TestClient(server.app) as client:
84+
r = client.put("/thing/floatsetting", json=2.0)
85+
assert r.status_code == 201
86+
r = client.get("/thing/floatsetting")
87+
assert r.json() == 2.0
88+
assert os.path.isfile(setting_file)
89+
with open(setting_file, "r", encoding="utf-8") as file_obj:
90+
# Check settings on file match expected dictionary
91+
assert json.load(file_obj) == _settings_dict(floatsetting=2.0)
92+
93+
94+
def test_settings_load(thing, server):
95+
"""Check settings can be loaded from disk when added to server"""
96+
setting_file = _get_setting_file(server, "/thing")
97+
setting_json = json.dumps(_settings_dict(floatsetting=3.0, stringsetting="bar"))
98+
# Create setting file
99+
os.makedirs(os.path.dirname(setting_file))
100+
with open(setting_file, "w", encoding="utf-8") as file_obj:
101+
file_obj.write(setting_json)
102+
# Add thing to server and check new settings are loaded
103+
server.add_thing(thing, "/thing")
104+
assert not thing.boolsetting
105+
assert thing.stringsetting == "bar"
106+
assert thing.floatsetting == 3.0
107+
108+
109+
def test_load_extra_settings(thing, server, caplog):
110+
"""Load from setting file. Extra setting in file should create a warning."""
111+
setting_file = _get_setting_file(server, "/thing")
112+
setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar")
113+
setting_dict["extra_setting"] = 33.33
114+
setting_json = json.dumps(setting_dict)
115+
# Create setting file
116+
os.makedirs(os.path.dirname(setting_file))
117+
with open(setting_file, "w", encoding="utf-8") as file_obj:
118+
file_obj.write(setting_json)
119+
120+
with caplog.at_level(logging.WARNING):
121+
# Add thing to server
122+
server.add_thing(thing, "/thing")
123+
assert len(caplog.records) == 1
124+
assert caplog.records[0].levelname == "WARNING"
125+
assert caplog.records[0].name == "labthings_fastapi.thing"
126+
127+
# Check other settings are loaded as expected
128+
assert not thing.boolsetting
129+
assert thing.stringsetting == "bar"
130+
assert thing.floatsetting == 3.0
131+
132+
133+
def test_try_loading_corrupt_settings(thing, server, caplog):
134+
"""Load from setting file. Extra setting in file should create a warning."""
135+
setting_file = _get_setting_file(server, "/thing")
136+
setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar")
137+
setting_json = json.dumps(setting_dict)
138+
# Cut the start off the json to so it can't be decoded.
139+
setting_json = setting_json[3:]
140+
# Create setting file
141+
os.makedirs(os.path.dirname(setting_file))
142+
with open(setting_file, "w", encoding="utf-8") as file_obj:
143+
file_obj.write(setting_json)
144+
145+
with caplog.at_level(logging.WARNING):
146+
# Add thing to server
147+
server.add_thing(thing, "/thing")
148+
assert len(caplog.records) == 1
149+
assert caplog.records[0].levelname == "WARNING"
150+
assert caplog.records[0].name == "labthings_fastapi.thing"
151+
152+
# Check default settings are loaded
153+
assert not thing.boolsetting
154+
assert thing.stringsetting == "foo"
155+
assert thing.floatsetting == 1.0

tests/test_thing.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
1+
import pytest
12
from labthings_fastapi.example_things import MyThing
3+
from labthings_fastapi.server import ThingServer
24

35

46
def test_td_validates():
57
"""This will raise an exception if it doesn't validate OK"""
68
thing = MyThing()
79
assert thing.validate_thing_description() is None
10+
11+
12+
def test_add_thing():
13+
"""Check that thing can be added to the server"""
14+
thing = MyThing()
15+
server = ThingServer()
16+
server.add_thing(thing, "/thing")
17+
18+
19+
def test_add_naughty_thing():
20+
"""Check that a thing trying to access server resources
21+
using .. is not allowed"""
22+
thing = MyThing()
23+
server = ThingServer()
24+
with pytest.raises(ValueError):
25+
server.add_thing(thing, "/../../../../bin")

0 commit comments

Comments
 (0)