Merge branch 'v3' into labthings-debug-logging-config

This commit is contained in:
Beth Probert 2026-03-05 10:27:05 +00:00
commit c3ad127f1c
19 changed files with 183 additions and 185 deletions

View file

@ -36,7 +36,7 @@ def main() -> None:
with open(config, "r", encoding="utf-8") as file_obj: with open(config, "r", encoding="utf-8") as file_obj:
config_dict = json.load(file_obj) config_dict = json.load(file_obj)
config_dict["things"]["smart_scan"]["kwargs"]["scans_folder"] = "/tmp/scans" config_dict["application_config"]["data_folder"] = "/tmp/data"
server = lt.ThingServer(things=config_dict["things"]) server = lt.ThingServer(things=config_dict["things"])
test_client = TestClient(server.app) test_client = TestClient(server.app)

View file

@ -14,7 +14,6 @@
"smart_scan": { "smart_scan": {
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",
"kwargs": { "kwargs": {
"scans_folder": "/var/openflexure/scans/",
"default_workflow": "histo_scan_workflow" "default_workflow": "histo_scan_workflow"
} }
}, },
@ -26,5 +25,9 @@
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
}, },
"settings_folder": "/var/openflexure/settings/", "settings_folder": "/var/openflexure/settings/",
"log_folder": "/var/openflexure/logs/" "application_config": {
"log_folder": "/var/openflexure/logs/",
"data_folder": "/var/openflexure/data/"
}
} }

View file

@ -4,5 +4,8 @@
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem" "system": "openflexure_microscope_server.things.system:OpenFlexureSystem"
}, },
"settings_folder": "./openflexure/settings/", "settings_folder": "./openflexure/settings/",
"log_folder": "./openflexure/logs/" "application_config": {
"log_folder": "./openflexure/logs/",
"data_folder": "./openflexure/data/"
}
} }

View file

@ -9,7 +9,6 @@
"smart_scan": { "smart_scan": {
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",
"kwargs": { "kwargs": {
"scans_folder": "./openflexure/scans/",
"default_workflow": "histo_scan_workflow" "default_workflow": "histo_scan_workflow"
} }
}, },
@ -20,5 +19,8 @@
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
}, },
"settings_folder": "./openflexure/settings/", "settings_folder": "./openflexure/settings/",
"log_folder": "./openflexure/logs/" "application_config": {
"log_folder": "./openflexure/logs/",
"data_folder": "./openflexure/data/"
}
} }

Binary file not shown.

View file

@ -18,7 +18,7 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
"labthings-fastapi==0.0.14", "labthings-fastapi==0.0.17",
"sangaboard", "sangaboard",
"camera-stage-mapping ~= 0.1.10", "camera-stage-mapping ~= 0.1.10",
"opencv-python-headless ~= 4.13.0", "opencv-python-headless ~= 4.13.0",

View file

@ -11,7 +11,8 @@ from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
import uvicorn import uvicorn
from fastapi.middleware.cors import CORSMiddleware # vue3 migration from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from uvicorn.main import Server from uvicorn.main import Server
import labthings_fastapi as lt import labthings_fastapi as lt
@ -35,6 +36,15 @@ DEVELOPER_MODE = os.getenv("OFM_SERVER_DEV_MODE", "false").lower() == "true"
_TEMPLATE_PATH = Path(__file__).with_name("fallback.html.jinja") _TEMPLATE_PATH = Path(__file__).with_name("fallback.html.jinja")
class OFMApplicationData(BaseModel):
"""Application data for the OpenFlexure Microscope."""
log_folder: str
"""The directory to save the logs in."""
data_folder: str
"""The directory for Things to save data in."""
def set_shutdown_function(shutdown_function: Callable[[], None]) -> None: def set_shutdown_function(shutdown_function: Callable[[], None]) -> None:
"""Ensure a function is called before the shutdown. """Ensure a function is called before the shutdown.
@ -62,11 +72,9 @@ def set_shutdown_function(shutdown_function: Callable[[], None]) -> None:
def customise_server( def customise_server(
server: lt.ThingServer, log_folder: str, scans_folder: Optional[str] server: lt.ThingServer, application_config: OFMApplicationData
) -> None: ) -> None:
"""Customise the server with additional endpoints, etc.""" """Customise the server with additional endpoints, etc."""
configure_logging(log_folder)
if DEVELOPER_MODE: if DEVELOPER_MODE:
# Allow CORS in developer mode for easier testing with the webapp # Allow CORS in developer mode for easier testing with the webapp
server.app.add_middleware( server.app.add_middleware(
@ -78,27 +86,13 @@ def customise_server(
) )
add_v2_endpoints(server) add_v2_endpoints(server)
add_static_files(server.app, scans_folder) add_static_files(server.app, application_config.data_folder)
# Add an endpoint to get the logs - (directly calling the FastAPI decorator) # Add an endpoint to get the logs - (directly calling the FastAPI decorator)
server.app.get("/log/")(retrieve_log) server.app.get("/log/")(retrieve_log)
server.app.get("/logfile/")(retrieve_log_from_file) server.app.get("/logfile/")(retrieve_log_from_file)
def _get_scans_dir(config: dict) -> Optional[str]:
"""Read the config and return the scans directory.
Return is None if there is no smart_scan thing loaded.
"""
if "smart_scan" in config["things"]:
try:
return config["things"]["smart_scan"]["kwargs"]["scans_folder"]
except KeyError as e:
msg = "Configuration error: smart scan should have scans_folder kwarg set"
raise RuntimeError(msg) from e
return None
def serve_from_cli(argv: Optional[list[str]] = None) -> None: def serve_from_cli(argv: Optional[list[str]] = None) -> None:
"""Start the server from the command line.""" """Start the server from the command line."""
args = lt.cli.parse_args(argv) args = lt.cli.parse_args(argv)
@ -112,17 +106,15 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
lt_config = None lt_config = None
server = None server = None
try: try:
lt_config, internal_config = _full_config_from_args(args) lt_config = _full_config_from_args(args)
# Validate our application data
if lt_config.application_config is None:
raise ValueError("No application configuration was supplied.")
application_config = OFMApplicationData(**lt_config.application_config)
configure_logging(application_config.log_folder)
server = lt.ThingServer.from_config(lt_config) server = lt.ThingServer.from_config(lt_config)
customise_server(server, application_config)
# If we want debug logging from labthings, the --debug argument
# must be used when starting the server.
if args.debug:
lt.logs.configure_thing_logger(logging.DEBUG)
customise_server(
server, internal_config["log_folder"], internal_config["scans_folder"]
)
def shutdown_call() -> None: def shutdown_call() -> None:
try: try:
@ -177,25 +169,18 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
raise e raise e
def _full_config_from_args(args: Namespace) -> tuple[ThingServerConfig, dict[str, Any]]: def _full_config_from_args(args: Namespace) -> ThingServerConfig:
"""Load configuration from LabThings args allowing patching. """Load configuration from LabThings args allowing patching.
This returns the labthings ThingServerConfig model and a dictionary of the config This returns the labthings ThingServerConfig model.
for the microscope.
This provides similar functionarlity to lt.cli.config_from_args except allows the This provides similar functionarlity to lt.cli.config_from_args except allows the
configuration file to specify a base config, and optionally patches. configuration file to specify a base config, and optionally patches.
""" """
internal_config = {"log_folder": "./openflexure/logs", "scans_folder": None}
# If no config file specified let LabThings handle it.
if not args.config: if not args.config:
return lt.cli.config_from_args(args), internal_config raise RuntimeError(
"OpenFlexure Microscope Server must have a configuration file specified."
)
patched_config = load_patched_config(args.config) patched_config = load_patched_config(args.config)
log_folder = patched_config.pop("log_folder", None) return ThingServerConfig(**patched_config)
if log_folder is not None:
internal_config["log_folder"] = log_folder
scans_folder = _get_scans_dir(patched_config)
if scans_folder is not None:
internal_config["scans_folder"] = scans_folder
return ThingServerConfig(**patched_config), internal_config

View file

@ -1,7 +1,6 @@
"""Add endpoints for static files to the underlying FastAPI server.""" """Add endpoints for static files to the underlying FastAPI server."""
import os import os
from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
@ -36,7 +35,7 @@ def add_static_file(app: FastAPI, fname: str, folder: str) -> None:
) )
def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None: def add_static_files(app: FastAPI, data_folder: str) -> None:
"""Add the static files responsible for the webapp app to the FastAPI app. """Add the static files responsible for the webapp app to the FastAPI app.
Note that any file in the root of the static dir will not be cached. However, the Note that any file in the root of the static dir will not be cached. However, the
@ -45,7 +44,7 @@ def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None:
important file not to cache is "index.html". important file not to cache is "index.html".
:param app: The FastAPI app to add to, in this case the OpenFlexure server :param app: The FastAPI app to add to, in this case the OpenFlexure server
:param scans_folder: The directory for the scans. :param data_folder: The directory for any data.
""" """
check_static_dir() check_static_dir()
@ -65,16 +64,17 @@ def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None:
name=f"static_{fname}", name=f"static_{fname}",
) )
# If scans folder is None, there is not smart scan thing. So nothing to mount. # We need a data folder
if scans_folder is not None: if data_folder is None:
# Mount the scan directory to .../scans/, to allow dzi viewing raise ValueError("No data folder is set, cannot start server")
if not os.path.isdir(scans_folder): # Mount the scan directory to .../data/, to allow dzi viewing
os.makedirs(scans_folder) if not os.path.isdir(data_folder):
app.mount( os.makedirs(data_folder)
"/scans/", app.mount(
StaticFiles(directory=scans_folder), "/data/",
name="scans", StaticFiles(directory=data_folder),
) name="data",
)
def check_static_dir() -> None: def check_static_dir() -> None:

View file

@ -3,3 +3,34 @@
The microscope can be extended to be used with other hardware by creating a package The microscope can be extended to be used with other hardware by creating a package
with other Things and including them in the LabThings-FastAPI config file. with other Things and including them in the LabThings-FastAPI config file.
""" """
import posixpath
from typing import Optional, Self
import labthings_fastapi as lt
class OFMThing(lt.Thing):
"""A custom LabThings Thing class for the OpenFlexure Microscope."""
_data_dir: Optional[str] = None
def __enter__(self) -> Self:
"""Set the data directory when the Thing is entered."""
# Note that the `application_config` was already validated when the
# server initialised.
application_config = self._thing_server_interface.application_config
if application_config is None:
raise ValueError("No application configuration was supplied.")
app_data_dir = application_config["data_folder"]
self._data_dir = posixpath.join(str(app_data_dir), self.path.strip("/"))
return self
@property
def data_dir(self) -> str:
"""The data directory for this thing."""
if self._data_dir is None:
raise RuntimeError(
"No data directory set. Has the LabThings server been started?"
)
return self._data_dir

View file

@ -604,7 +604,7 @@ class BaseCamera(lt.Thing):
return self._background_detector_name return self._background_detector_name
@background_detector_name.setter @background_detector_name.setter
def _set_background_detector_name(self, name: str) -> None: def _set_background_detector_name(self, name: Optional[str]) -> None:
"""Validate and set background_detector_name.""" """Validate and set background_detector_name."""
if name not in self._all_background_detectors: if name not in self._all_background_detectors:
self.logger.warning(f"{name} is not a valid background detector name.") self.logger.warning(f"{name} is not a valid background detector name.")

View file

@ -32,6 +32,7 @@ from pydantic import BaseModel, PlainSerializer
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server import scan_directories, stitching from openflexure_microscope_server import scan_directories, stitching
from openflexure_microscope_server.things import OFMThing
from openflexure_microscope_server.utilities import coerce_thing_selector from openflexure_microscope_server.utilities import coerce_thing_selector
# Things # Things
@ -120,7 +121,7 @@ def _scan_running(
return scan_running_wrapper return scan_running_wrapper
class SmartScanThing(lt.Thing): class SmartScanThing(OFMThing):
"""A Thing for scanning samples and interacting with past scans. """A Thing for scanning samples and interacting with past scans.
SmartScanThing exposes all functionality for automatically scanning samples, SmartScanThing exposes all functionality for automatically scanning samples,
@ -135,23 +136,22 @@ class SmartScanThing(lt.Thing):
def __init__( def __init__(
self, self,
thing_server_interface: lt.ThingServerInterface, thing_server_interface: lt.ThingServerInterface,
scans_folder: str,
default_workflow: str, default_workflow: str,
) -> None: ) -> None:
"""Initialise a SmartScanThing saving to and loading from the input directory. """Initialise a SmartScanThing saving to and loading from the input directory.
:param scans_folder: This is the path to the directory where all scans will be :param default_workflow: The default workflows that smart scan uses if nothing
saved. Any scans already in this directory will be accessible through the is set in settings.
HTTP interface.
""" """
super().__init__(thing_server_interface) super().__init__(thing_server_interface)
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._scan_lock = threading.Lock() self._scan_lock = threading.Lock()
self._default_workflow = default_workflow self._default_workflow = default_workflow
self._workflow_name = default_workflow self._workflow_name = default_workflow
def __enter__(self) -> Self: def __enter__(self) -> Self:
"""Open hardware connection when the Thing context manager is opened.""" """Open hardware connection when the Thing context manager is opened."""
super().__enter__()
self._scan_dir_manager = scan_directories.ScanDirectoryManager(self.data_dir)
valid_name = coerce_thing_selector( valid_name = coerce_thing_selector(
thing_mapping=self._all_workflows, thing_mapping=self._all_workflows,
selected=self.workflow_name, selected=self.workflow_name,

View file

@ -33,7 +33,10 @@ def test_env():
"""Yield a server with a very basic configuration.""" """Yield a server with a very basic configuration."""
with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj: with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj) config_dict = json.load(f_obj)
with LabThingsTestEnv(things=config_dict["things"]) as env: with LabThingsTestEnv(
things=config_dict["things"],
application_config=config_dict["application_config"],
) as env:
yield env yield env

View file

@ -34,6 +34,7 @@ class LabThingsTestEnv:
self, self,
things: Mapping[str, lt.Thing | str], things: Mapping[str, lt.Thing | str],
settings_folder: Optional[str] = None, settings_folder: Optional[str] = None,
application_config: Optional[Mapping[str, Any]] = None,
) -> None: ) -> None:
"""Initialise the test environment. """Initialise the test environment.
@ -47,6 +48,7 @@ class LabThingsTestEnv:
self._test_client: Optional[TestClient] self._test_client: Optional[TestClient]
self._things_config = things self._things_config = things
self._settings_folder = settings_folder self._settings_folder = settings_folder
self._application_config = application_config
self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None
def __enter__(self) -> Self: def __enter__(self) -> Self:
@ -55,7 +57,9 @@ class LabThingsTestEnv:
self._tmp_dir_obj = tempfile.TemporaryDirectory() self._tmp_dir_obj = tempfile.TemporaryDirectory()
self._settings_folder = self._tmp_dir_obj.name self._settings_folder = self._tmp_dir_obj.name
self._server = lt.ThingServer( self._server = lt.ThingServer(
things=self._things_config, settings_folder=self._settings_folder things=self._things_config,
settings_folder=self._settings_folder,
application_config=self._application_config,
) )
self._test_client = TestClient(self._server.app) self._test_client = TestClient(self._server.app)
self._test_client.__enter__() self._test_client.__enter__()

View file

@ -81,7 +81,7 @@ def test_add_static_with_no_static_dir(mocker):
) )
# Should raise as the mock static dir does not exist # Should raise as the mock static dir does not exist
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
serve_static_files.add_static_files(mock_app, scans_folder=None) serve_static_files.add_static_files(mock_app, data_folder=None)
assert mock_app.get.call_count == 0 assert mock_app.get.call_count == 0
@ -126,7 +126,8 @@ def test_add_static_files(mock_static_dir, mocker):
) )
# Get the wrapper function from the mocked decorator # Get the wrapper function from the mocked decorator
wrapper = mock_app.get.return_value wrapper = mock_app.get.return_value
serve_static_files.add_static_files(mock_app, scans_folder=None) with tempfile.TemporaryDirectory() as datadir:
serve_static_files.add_static_files(mock_app, data_folder=datadir)
# Get should have been called twice to create a route for index # Get should have been called twice to create a route for index
assert mock_app.get.call_count == 2 assert mock_app.get.call_count == 2
@ -151,23 +152,11 @@ def test_add_static_files(mock_static_dir, mocker):
assert second_wrapped().headers["Pragma"] == "no-cache" assert second_wrapped().headers["Pragma"] == "no-cache"
# Also should have mounted both dirs # Also should have mounted both dirs
assert mock_app.mount.call_count == 1 assert mock_app.mount.call_count == 2
mounted_path = mock_app.mount.call_args.args[0] mounted_path = mock_app.mount.call_args_list[0].args[0]
mounted_dir = mock_app.mount.call_args.args[1].directory mounted_dir = mock_app.mount.call_args_list[0].args[1].directory
assert "/assets/" in mounted_path assert "/assets/" in mounted_path
assert os.path.join(mock_static_dir, "assets") in mounted_dir assert os.path.join(mock_static_dir, "assets") in mounted_dir
assert mock_app.mount.call_args_list[1].args[0] == "/data/"
def test_add_static_files_with_scan_dir(mock_static_dir, mocker): assert mock_app.mount.call_args_list[1].args[1].directory == datadir
"""Check the scan dir mounts if supplied."""
mock_app = mocker.Mock()
mocker.patch(
"openflexure_microscope_server.server.serve_static_files.STATIC_PATH",
mock_static_dir,
)
with tempfile.TemporaryDirectory() as scandir:
serve_static_files.add_static_files(mock_app, scans_folder=scandir)
# The scan dir should be the last to mount so can use call args. It should mount
# at /scans/
assert mock_app.mount.call_args.args[0] == "/scans/"
assert mock_app.mount.call_args.args[1].directory == scandir

View file

@ -15,7 +15,8 @@ from .test_server_config import SIM_CONFIG
def test_no_config(): def test_no_config():
"""Check that an error is thrown if no configuration is set for the microspe via CLI.""" """Check that an error is thrown if no configuration is set for the microspe via CLI."""
with pytest.raises(RuntimeError, match="No configuration"): msg = "OpenFlexure Microscope Server must have a configuration file specified."
with pytest.raises(RuntimeError, match=msg):
ofm_server.serve_from_cli([]) ofm_server.serve_from_cli([])
@ -35,6 +36,8 @@ def test_successful_start(mocker, caplog):
"openflexure_microscope_server.server.lt.ThingServer.from_config", "openflexure_microscope_server.server.lt.ThingServer.from_config",
return_value=mock_server, return_value=mock_server,
) )
# Also mock configure_logging or it will touch a load of code and log.
mock_log_configure = mocker.patch.object(ofm_server, "configure_logging")
# Also mock customisation or it will try to access the hard drive and make files. # Also mock customisation or it will try to access the hard drive and make files.
mock_customise = mocker.patch.object(ofm_server, "customise_server") mock_customise = mocker.patch.object(ofm_server, "customise_server")
# And mock uvicorn.run as we don't want to start a server process # And mock uvicorn.run as we don't want to start a server process
@ -43,7 +46,9 @@ def test_successful_start(mocker, caplog):
# Run the mock CLI # Run the mock CLI
ofm_server.serve_from_cli(["-c", SIM_CONFIG]) ofm_server.serve_from_cli(["-c", SIM_CONFIG])
# Check that the server was customised and the run # Check that the logging was configures, the server was customised, and uvicorn was
# run
assert mock_log_configure.call_count == 1
assert mock_customise.call_count == 1 assert mock_customise.call_count == 1
assert mock_uvicorn_run.call_count == 1 assert mock_uvicorn_run.call_count == 1
# Read the log config that was set # Read the log config that was set

View file

@ -1,9 +1,7 @@
"""Test server booting and shut down.""" """Test server booting and shut down."""
import json
import os import os
from argparse import Namespace from argparse import Namespace
from copy import deepcopy
import pytest import pytest
@ -12,11 +10,13 @@ from labthings_fastapi.server.config_model import ThingServerConfig
# Import as ofm server to attempt to minimise confusion with server as a var in other # Import as ofm server to attempt to minimise confusion with server as a var in other
# functions and also FastAPI `Server`. # functions and also FastAPI `Server`.
from openflexure_microscope_server import server as ofm_server from openflexure_microscope_server import server as ofm_server
from openflexure_microscope_server.server import OFMApplicationData
THIS_DIR = os.path.dirname(os.path.abspath(__file__)) THIS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR)) REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR))
FULL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_full.json") FULL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_full.json")
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
MANUAL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_manual.json")
@pytest.mark.parametrize("side_effect", [None, RuntimeError("Mock")]) @pytest.mark.parametrize("side_effect", [None, RuntimeError("Mock")])
@ -60,7 +60,6 @@ def test_customise_server(mocker):
# but also to limit excess coverage reporting. # but also to limit excess coverage reporting.
mocked_add_static = mocker.patch.object(ofm_server, "add_static_files") mocked_add_static = mocker.patch.object(ofm_server, "add_static_files")
mocked_v2_endpoints = mocker.patch.object(ofm_server, "add_v2_endpoints") mocked_v2_endpoints = mocker.patch.object(ofm_server, "add_v2_endpoints")
mocked_configure_logging = mocker.patch.object(ofm_server, "configure_logging")
mocked_retrieve_log = mocker.patch.object(ofm_server, "retrieve_log") mocked_retrieve_log = mocker.patch.object(ofm_server, "retrieve_log")
mocked_retrieve_log_file = mocker.patch.object(ofm_server, "retrieve_log_from_file") mocked_retrieve_log_file = mocker.patch.object(ofm_server, "retrieve_log_from_file")
@ -68,13 +67,15 @@ def test_customise_server(mocker):
# The wrapper returned for app.get so we can see what functions are decorated. # The wrapper returned for app.get so we can see what functions are decorated.
wrapper = mock_app.get.return_value wrapper = mock_app.get.return_value
application_data = OFMApplicationData(
log_folder="mock_log_folder", data_folder="mock_data_folder"
)
# Finally we can run it! # Finally we can run it!
ofm_server.customise_server(mock_server, "mock_log_folder", "mock_scan_folder") ofm_server.customise_server(mock_server, application_data)
# Check each internal customisation function is called # Check each internal customisation function is called
assert mocked_add_static.call_count == 1 assert mocked_add_static.call_count == 1
assert mocked_v2_endpoints.call_count == 1 assert mocked_v2_endpoints.call_count == 1
assert mocked_configure_logging.call_count == 1
# Check app.get adds two routes # Check app.get adds two routes
assert mock_app.get.call_count == 2 assert mock_app.get.call_count == 2
assert wrapper.call_count == 2 assert wrapper.call_count == 2
@ -88,67 +89,26 @@ def test_customise_server(mocker):
assert mocked_retrieve_log_file assert mocked_retrieve_log_file
@pytest.mark.parametrize(
("config_file", "expected_scan_dir"),
[(FULL_CONFIG, "/var/openflexure/scans/"), (SIM_CONFIG, "./openflexure/scans/")],
)
def test_get_scans_dir_ok(config_file, expected_scan_dir):
"""Test the _get_scans_dir function and also check the standard config files."""
with open(config_file, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj)
assert ofm_server._get_scans_dir(config_dict) == expected_scan_dir
def test_get_scans_dir_no_smart_scan():
"""Test _get_scans_dir with no SmartScanThing."""
# Load the standard config dict
with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj)
# Delete smart scan
del config_dict["things"]["smart_scan"]
# No SmartScanThing, should return None
assert ofm_server._get_scans_dir(config_dict) is None
def test_get_scans_dir_bad_smart_scan_config():
"""Test _get_scans_dir with a SmartScanThing that doesn't set a config dir."""
# Load the standard config dict
with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj)
# Copy the dictionary
broken_config = deepcopy(config_dict)
# Delete all the smart scan kwargs
del broken_config["things"]["smart_scan"]["kwargs"]
# Creates an Error
with pytest.raises(RuntimeError):
ofm_server._get_scans_dir(broken_config)
# Same thing should happen if just the scans_folder key is deleted
broken_config = deepcopy(config_dict)
del broken_config["things"]["smart_scan"]["kwargs"]
# Creates an Error
with pytest.raises(RuntimeError):
ofm_server._get_scans_dir(broken_config)
def test_full_config_from_args_json(): def test_full_config_from_args_json():
"""Check that _full_config_from_args returns as expected if json is supplied.""" """Check that _full_config_from_args errors if json config is supplied."""
msg = "OpenFlexure Microscope Server must have a configuration file specified."
args = Namespace( args = Namespace(
config=None, config=None,
json='{"things":{"example": "labthings_fastapi.example_things:MyThing"}}', json='{"things":{"example": "labthings_fastapi.example_things:MyThing"}}',
) )
lt_conf, ofm_conf = ofm_server._full_config_from_args(args) with pytest.raises(RuntimeError, match=msg):
# If json is supplied OFM config is default. As the json is passed dircectly to ofm_server._full_config_from_args(args)
# The Pydantic Mocel in LabThings. No custom data allowed.
assert isinstance(lt_conf, ThingServerConfig)
assert "log_folder" in ofm_conf
assert ofm_conf["log_folder"] == "./openflexure/logs"
assert "scans_folder" in ofm_conf
assert ofm_conf["scans_folder"] is None
def test_full_config_from_args_full(mocker): @pytest.mark.parametrize(
("config", "log_dir", "data_dir"),
[
(FULL_CONFIG, "/var/openflexure/logs/", "/var/openflexure/data/"),
(SIM_CONFIG, "./openflexure/logs/", "./openflexure/data/"),
(MANUAL_CONFIG, "./openflexure/logs/", "./openflexure/data/"),
],
)
def test_full_config_from_args(config, log_dir, data_dir, mocker):
"""Check that _full_config_from_args returns as expected if json is supplied.""" """Check that _full_config_from_args returns as expected if json is supplied."""
# Mock picamera library so LabThings can create its model. # Mock picamera library so LabThings can create its model.
mocker.patch.dict( mocker.patch.dict(
@ -159,12 +119,13 @@ def test_full_config_from_args_full(mocker):
"picamera2.outputs": mocker.Mock(), "picamera2.outputs": mocker.Mock(),
}, },
) )
args = Namespace(config=FULL_CONFIG, json=None) args = Namespace(config=config, json=None)
lt_conf, ofm_conf = ofm_server._full_config_from_args(args) lt_conf = ofm_server._full_config_from_args(args)
# If json is supplied OFM config is default. As the json is passed dircectly to # If json is supplied OFM config is default. As the json is passed dircectly to
# The Pydantic Mocel in LabThings. No custom data allowed. # The Pydantic Mocel in LabThings. No custom data allowed.
assert isinstance(lt_conf, ThingServerConfig) assert isinstance(lt_conf, ThingServerConfig)
assert "log_folder" in ofm_conf # Check the configuration validates
assert ofm_conf["log_folder"] == "/var/openflexure/logs/" application_config = OFMApplicationData(**lt_conf.application_config)
assert "scans_folder" in ofm_conf # And has the correct values
assert ofm_conf["scans_folder"] == "/var/openflexure/scans/" assert application_config.log_folder == log_dir
assert application_config.data_folder == data_dir

View file

@ -41,7 +41,7 @@ from openflexure_microscope_server.things.smart_scan import (
# Use our own dir in the root temp dir not a dynamically generated one so we # Use our own dir in the root temp dir not a dynamically generated one so we
# have some control of when it is deleted # have some control of when it is deleted
SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans") SCAN_DIR = os.path.join(tempfile.gettempdir(), "smartscanthing")
def _clear_scan_dir() -> None: def _clear_scan_dir() -> None:
@ -51,17 +51,31 @@ def _clear_scan_dir() -> None:
@pytest.fixture @pytest.fixture
def smart_scan_thing(): def smart_scan_thing(mocker):
"""Return a smart scan thing as a fixture.""" """Return a smart scan thing as a fixture."""
return create_thing_without_server( thing = create_thing_without_server(
SmartScanThing, SmartScanThing,
scans_folder=SCAN_DIR,
default_workflow="mock-_all_workflows", default_workflow="mock-_all_workflows",
mock_all_slots=True, mock_all_slots=True,
) )
type(thing._thing_server_interface).application_config = mocker.PropertyMock(
return_value={"data_folder": tempfile.gettempdir()}
)
return thing
def custom_smart_scan_thing(default_workflow, all_workflows): @pytest.fixture
def entered_smart_scan_thing(smart_scan_thing):
"""Yield a smart scan thing as a fixture that has been entred.
This will make a scan directory manager. This fixture also clears the scan dir
"""
_clear_scan_dir()
with smart_scan_thing:
yield smart_scan_thing
def custom_smart_scan_thing(default_workflow, all_workflows, mocker):
"""Set up a custom smart scan thing with workflows adjusted. """Set up a custom smart scan thing with workflows adjusted.
This allows setting a default workflow and to adjust all workflows from simple This allows setting a default workflow and to adjust all workflows from simple
@ -69,10 +83,13 @@ def custom_smart_scan_thing(default_workflow, all_workflows):
""" """
smart_scan_thing = create_thing_without_server( smart_scan_thing = create_thing_without_server(
SmartScanThing, SmartScanThing,
scans_folder=SCAN_DIR,
default_workflow=default_workflow, default_workflow=default_workflow,
mock_all_slots=True, mock_all_slots=True,
) )
interface_type = type(smart_scan_thing._thing_server_interface)
interface_type.application_config = mocker.PropertyMock(
return_value={"data_folder": tempfile.gettempdir()}
)
# Pop the existing mock workflow and add specified ones (if any) # Pop the existing mock workflow and add specified ones (if any)
smart_scan_thing._all_workflows.pop("mock-_all_workflows") smart_scan_thing._all_workflows.pop("mock-_all_workflows")
for key, thing in all_workflows.items(): for key, thing in all_workflows.items():
@ -81,12 +98,13 @@ def custom_smart_scan_thing(default_workflow, all_workflows):
return smart_scan_thing return smart_scan_thing
def test_initial_properties(smart_scan_thing): def test_initial_properties(entered_smart_scan_thing):
"""Check the initial values of properties. """Check the initial values of properties.
Test properties of SmartScanThing are available without a ThingServer Test properties of SmartScanThing are available without a ThingServer
and return expected default values. and return expected default values.
""" """
smart_scan_thing = entered_smart_scan_thing
assert smart_scan_thing._scan_dir_manager.base_dir == SCAN_DIR assert smart_scan_thing._scan_dir_manager.base_dir == SCAN_DIR
assert smart_scan_thing.latest_scan_name is None assert smart_scan_thing.latest_scan_name is None
@ -168,9 +186,9 @@ SELECTOR_CASES = [
@pytest.mark.parametrize("case", SELECTOR_CASES) @pytest.mark.parametrize("case", SELECTOR_CASES)
def test_workflow_set_on_enter(case, check_side_effect): def test_workflow_set_on_enter(case, check_side_effect, mocker):
"""Check workflow is set on enter.""" """Check workflow is set on enter."""
smart_scan_thing = custom_smart_scan_thing(case.default_wf, case.workflows) smart_scan_thing = custom_smart_scan_thing(case.default_wf, case.workflows, mocker)
with check_side_effect(case.side_effect, match=case.match): with check_side_effect(case.side_effect, match=case.match):
# Load in "loaded" as the sever would # Load in "loaded" as the sever would
smart_scan_thing._workflow_name = case.loaded_wf smart_scan_thing._workflow_name = case.loaded_wf
@ -178,14 +196,13 @@ def test_workflow_set_on_enter(case, check_side_effect):
assert smart_scan_thing._workflow_name == case.expected_wf assert smart_scan_thing._workflow_name == case.expected_wf
def test_setting_workflows(caplog): def test_setting_workflows(caplog, mocker):
"""Check that setting workflow works, or warns if incorrect.""" """Check that setting workflow works, or warns if incorrect."""
workflows = { workflows = {
"foo": mock.MagicMock(spec=ScanWorkflow), "foo": mock.MagicMock(spec=ScanWorkflow),
"bar": mock.MagicMock(spec=ScanWorkflow), "bar": mock.MagicMock(spec=ScanWorkflow),
} }
smart_scan_thing = custom_smart_scan_thing("foo", workflows, mocker)
smart_scan_thing = custom_smart_scan_thing("foo", workflows)
with caplog.at_level(logging.WARNING), smart_scan_thing: with caplog.at_level(logging.WARNING), smart_scan_thing:
assert smart_scan_thing._workflow_name == "foo" assert smart_scan_thing._workflow_name == "foo"
assert smart_scan_thing._workflow is workflows["foo"] assert smart_scan_thing._workflow is workflows["foo"]
@ -225,15 +242,13 @@ def test_inaccessible_scan_methods(smart_scan_thing):
smart_scan_thing.ongoing_scan smart_scan_thing.ongoing_scan
def test_private_delete_scan(smart_scan_thing, caplog): def test_private_delete_scan(entered_smart_scan_thing, caplog):
"""Test the private _delete_scan method deletes directories or warns if it can't.""" """Test the private _delete_scan method deletes directories or warns if it can't."""
_clear_scan_dir() smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_name = "fake_scan_0001" fake_scan_name = "fake_scan_0001"
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
# Make the outer scan dir, but not the one to delete
os.makedirs(SCAN_DIR)
# Attempt to delete the fake scan. Expect it to fail and provide a warning # Attempt to delete the fake scan. Expect it to fail and provide a warning
deleted = smart_scan_thing._delete_scan(fake_scan_name) deleted = smart_scan_thing._delete_scan(fake_scan_name)
assert not deleted assert not deleted
@ -251,16 +266,13 @@ def test_private_delete_scan(smart_scan_thing, caplog):
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_public_delete_scan(smart_scan_thing, caplog): def test_public_delete_scan(entered_smart_scan_thing, caplog):
"""Test the delete_scan API call deletes directories or warns if it can't.""" """Test the delete_scan API call deletes directories or warns if it can't."""
_clear_scan_dir() smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_name = "fake_scan_0001" fake_scan_name = "fake_scan_0001"
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
# Make the outer scan dir, but not the one to delete
os.makedirs(SCAN_DIR)
# Attempt to delete the fake scan. Expect it to fail # Attempt to delete the fake scan. Expect it to fail
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
smart_scan_thing.delete_scan(fake_scan_name) smart_scan_thing.delete_scan(fake_scan_name)
@ -280,9 +292,9 @@ def test_public_delete_scan(smart_scan_thing, caplog):
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_delete_all_scans(smart_scan_thing, caplog): def test_delete_all_scans(entered_smart_scan_thing, caplog):
"""Check the delete_all_scan API really does delete all the scans.""" """Check the delete_all_scan API really does delete all the scans."""
_clear_scan_dir() smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_names = [ fake_scan_names = [
"fake_scan_0001", "fake_scan_0001",
@ -346,15 +358,15 @@ def _run_only_outer_scan(
return smart_scan_thing, exec_info return smart_scan_thing, exec_info
def test_outer_scan(smart_scan_thing, mocker): def test_outer_scan(entered_smart_scan_thing, mocker):
"""Test setup and teardown of the scan.""" """Test setup and teardown of the scan."""
mock_ss_thing, exec_info = _run_only_outer_scan(smart_scan_thing, mocker) mock_ss_thing, exec_info = _run_only_outer_scan(entered_smart_scan_thing, mocker)
assert exec_info is None assert exec_info is None
# Checked the mocked _run_scan was run exactly once # Checked the mocked _run_scan was run exactly once
assert mock_ss_thing._run_scan.call_count == 1 assert mock_ss_thing._run_scan.call_count == 1
def test_outer_scan_wo_sample_skip(smart_scan_thing, mocker): def test_outer_scan_wo_sample_skip(entered_smart_scan_thing, mocker):
"""Test setup and teardown of the scan.""" """Test setup and teardown of the scan."""
def _set_skip_background(mock_ss_thing): def _set_skip_background(mock_ss_thing):
@ -363,7 +375,7 @@ def test_outer_scan_wo_sample_skip(smart_scan_thing, mocker):
mock_ss_thing.__dict__["skip_background"] = False mock_ss_thing.__dict__["skip_background"] = False
mock_ss_thing, exec_info = _run_only_outer_scan( mock_ss_thing, exec_info = _run_only_outer_scan(
smart_scan_thing, mocker, _set_skip_background entered_smart_scan_thing, mocker, _set_skip_background
) )
assert exec_info is None assert exec_info is None

View file

@ -81,7 +81,7 @@ export default {
computed: { computed: {
selectedScanDZI() { selectedScanDZI() {
if (this.selectedScan && this.selectedScan.dzi) { if (this.selectedScan && this.selectedScan.dzi) {
return `${this.baseUri}/scans/${this.selectedScan.name}/images/${this.selectedScan.dzi}`; return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} }
return null; return null;
}, },

View file

@ -119,7 +119,7 @@ export default {
}, },
selectedScanDZI() { selectedScanDZI() {
if (this.selectedScan && this.selectedScan.dzi != "") { if (this.selectedScan && this.selectedScan.dzi != "") {
return `${this.$store.getters.baseUri}/scans/${this.selectedScan.name}/images/${this.selectedScan.dzi}`; return `${this.$store.getters.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} else { } else {
return null; return null;
} }