Skip to content

Commit 976bb12

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

File tree

8 files changed

+255
-15
lines changed

8 files changed

+255
-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: 21 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,10 @@
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\-_]+\/)+$")
2026

2127
class ThingServer:
2228
def __init__(self, settings_folder: Optional[str] = None):
@@ -71,13 +77,26 @@ def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance:
7177
)
7278

7379
def add_thing(self, thing: Thing, path: str):
74-
"""Add a thing to the server"""
80+
"""Add a thing to the server
81+
82+
:param thing: The thing to add to the server.
83+
:param path: the relative path to access the thing on the server. Must only
84+
contain alphanumeric characters, hyphens, or underscores.
85+
"""
86+
# Ensure leading and trailing /
7587
if not path.endswith("/"):
7688
path += "/"
89+
if not path.startswith("/"):
90+
path = "/" + path
91+
if PATH_REGEX.match(path) is None:
92+
msg = (
93+
f"{path} contains unsafe characters. Use only alphanumeric "
94+
"characters, hyphens and underscores"
95+
)
96+
raise ValueError(msg)
7797
if path in self._things:
7898
raise KeyError(f"{path} has already been added to this thing server.")
7999
self._things[path] = thing
80-
# TODO: check for illegal things in `path` - potential security issue.
81100
settings_folder = os.path.join(self.settings_folder, path.lstrip("/"))
82101
os.makedirs(settings_folder, exist_ok=True)
83102
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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
from labthings_fastapi.exceptions import NotConnectedToServerError
15+
16+
17+
class TestThing(Thing):
18+
boolsetting = ThingSetting(bool, False, description="A boolean setting")
19+
stringsetting = ThingSetting(str, "foo", description="A string setting")
20+
21+
_float = 1.0
22+
23+
@thing_setting
24+
def floatsetting(self) -> float:
25+
return self._float
26+
27+
@floatsetting.setter
28+
def floatsetting(self, value: float):
29+
self._float = value
30+
31+
@thing_action
32+
def toggle_boolsetting(self):
33+
self.boolsetting = not self.boolsetting
34+
35+
@thing_action
36+
def toggle_boolsetting_from_thread(self):
37+
t = Thread(target=self.toggle_boolsetting)
38+
t.start()
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+
def _settings_dict(
45+
boolsetting=False,
46+
floatsetting=1.0,
47+
stringsetting="foo"
48+
):
49+
"""Return the expected settings dictionary
50+
51+
Parameters can be updated from default values
52+
"""
53+
return {
54+
"boolsetting": boolsetting,
55+
"floatsetting": floatsetting,
56+
"stringsetting": stringsetting
57+
}
58+
59+
@pytest.fixture
60+
def thing():
61+
return TestThing()
62+
63+
@pytest.fixture
64+
def server():
65+
with tempfile.TemporaryDirectory() as tempdir:
66+
# Yield server rather than return so that the temp directory isn't cleaned up
67+
# until after the test is run
68+
yield ThingServer(settings_folder=tempdir)
69+
70+
def test_setting_available(thing):
71+
"""Check default settings are available before connecting to server"""
72+
assert thing.boolsetting == False
73+
assert thing.stringsetting == "foo"
74+
assert thing.floatsetting == 1.0
75+
76+
def test_settings_save(thing, server):
77+
"""Check updated settings are saved to disk"""
78+
setting_file = _get_setting_file(server, "/thing")
79+
server.add_thing(thing, "/thing")
80+
# No setting file created when first added
81+
assert not os.path.isfile(setting_file)
82+
with TestClient(server.app) as client:
83+
r = client.put("/thing/floatsetting", json=2.0)
84+
assert r.status_code == 201
85+
r = client.get("/thing/floatsetting")
86+
assert r.json() == 2.0
87+
assert os.path.isfile(setting_file)
88+
with open(setting_file, "r", encoding="utf-8") as file_obj:
89+
# Check settings on file match expected dictionary
90+
assert json.load(file_obj) == _settings_dict(floatsetting=2.0)
91+
92+
def test_settings_load(thing, server):
93+
"""Check settings can be loaded from disk when added to server"""
94+
setting_file = _get_setting_file(server, "/thing")
95+
setting_json = json.dumps(_settings_dict(floatsetting=3.0, stringsetting="bar"))
96+
# Create setting file
97+
os.makedirs(os.path.dirname(setting_file))
98+
with open(setting_file, "w", encoding="utf-8") as file_obj:
99+
file_obj.write(setting_json)
100+
# Add thing to server and check new settings are loaded
101+
server.add_thing(thing, "/thing")
102+
assert thing.boolsetting == False
103+
assert thing.stringsetting == "bar"
104+
assert thing.floatsetting == 3.0
105+
106+
def test_load_extra_settings(thing, server, caplog):
107+
"""Load from setting file. Extra setting in file should create a warning."""
108+
setting_file = _get_setting_file(server, "/thing")
109+
setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar")
110+
setting_dict["extra_setting"] = 33.33
111+
setting_json = json.dumps(setting_dict)
112+
# Create setting file
113+
os.makedirs(os.path.dirname(setting_file))
114+
with open(setting_file, "w", encoding="utf-8") as file_obj:
115+
file_obj.write(setting_json)
116+
117+
with caplog.at_level(logging.WARNING):
118+
# Add thing to server
119+
server.add_thing(thing, "/thing")
120+
assert len(caplog.records) == 1
121+
assert caplog.records[0].levelname == "WARNING"
122+
assert caplog.records[0].name == "labthings_fastapi.thing"
123+
124+
# Check other settings are loaded as expected
125+
assert thing.boolsetting == False
126+
assert thing.stringsetting == "bar"
127+
assert thing.floatsetting == 3.0
128+
129+
def test_try_loading_corrupt_settings(thing, server, caplog):
130+
"""Load from setting file. Extra setting in file should create a warning."""
131+
setting_file = _get_setting_file(server, "/thing")
132+
setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar")
133+
setting_json = json.dumps(setting_dict)
134+
# Cut the start off the json to so it can't be decoded.
135+
setting_json = setting_json[3:]
136+
# Create setting file
137+
os.makedirs(os.path.dirname(setting_file))
138+
with open(setting_file, "w", encoding="utf-8") as file_obj:
139+
file_obj.write(setting_json)
140+
141+
with caplog.at_level(logging.WARNING):
142+
# Add thing to server
143+
server.add_thing(thing, "/thing")
144+
assert len(caplog.records) == 1
145+
assert caplog.records[0].levelname == "WARNING"
146+
assert caplog.records[0].name == "labthings_fastapi.thing"
147+
148+
# Check default settings are loaded
149+
assert thing.boolsetting == False
150+
assert thing.stringsetting == "foo"
151+
assert thing.floatsetting == 1.0

tests/test_thing.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
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+
def test_add_thing():
12+
"""Check that thing can be added to the server"""
13+
thing = MyThing()
14+
server = ThingServer()
15+
server.add_thing(thing, "/thing")
16+
17+
def test_add_naughty_thing():
18+
"""Check that a thing trying to access server resources
19+
using .. is not allowed"""
20+
thing = MyThing()
21+
server = ThingServer()
22+
with pytest.raises(ValueError):
23+
server.add_thing(thing, "/../../../../bin")

0 commit comments

Comments
 (0)