Add runtime checkable protocol for checking gallery compatibility.

This commit is contained in:
Julian Stirling 2026-05-06 14:42:44 +01:00
parent 93d7d75b0d
commit 4583151345
3 changed files with 181 additions and 17 deletions

View file

@ -5,13 +5,38 @@ the Things that capture the Data. This thing just provides a unified way for the
front end to access the data.
"""
from typing import Mapping, Optional
from typing import Mapping, Optional, Protocol, Self, runtime_checkable
from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.things import OFMThing
@runtime_checkable
class GalleryCompatibleThing(Protocol):
"""Protocol for checking Things using the gallery are complete.
Note that runtime checkable protocols only check that the methods exist, not the
full signatures. This means that anything attempting to define the methods will
be picked up, but may throw an error when used.
"""
# Ensure it is a Thing:
_thing_server_interface: lt.ThingServerInterface
# Ensure it is an OFMThing:
show_in_gallery: bool
gallery_data_name: str
gallery_data_schema: type[BaseModel]
# Ignore D102: No docstrings for the protocol.
def get_gallery_data(self) -> list[BaseModel]: ... # noqa: D102
class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery."""
@ -23,9 +48,33 @@ class GalleryThing(lt.Thing):
def gallery_providing_things(self) -> Mapping[str, OFMThing]:
"""All Things that provide data to the gallery."""
if self._gallery_providing_things is None:
self._gallery_providing_things = {
name: thing
for name, thing in self.all_ofm_things.items()
if thing.show_in_gallery
}
raise RuntimeError(
"Cannot access gallery_providing_things before server has started."
)
return self._gallery_providing_things
def __enter__(self) -> Self:
"""Check for all gallery providing things on server startup."""
self._set_gallery_providers()
def _set_gallery_providers(self) -> None:
"""Set the mapping of gallery providing things.
This is called on ``__enter__`` when the server starts.
"""
gallery_providers = {
name: thing
for name, thing in self.all_ofm_things.items()
if thing.show_in_gallery
}
# cache initial list of keys as it may change in the loop
keys = list(gallery_providers.keys())
for key in keys:
if not isinstance(gallery_providers[key], GalleryCompatibleThing):
self.logger.error(
f"Data from {key} cannot be shown in gallery as it does not "
"provide all necessary properties/methods for the gallery."
)
gallery_providers.pop(key)
self._gallery_providing_things = gallery_providers

View file

@ -179,6 +179,26 @@ class SmartScanThing(OFMThing):
# Register with gallery.
_show_in_gallery = True
@property
def gallery_data_name(self) -> str:
"""Name under which data shows up in gallery."""
return "Scans"
@property
def gallery_data_schema(self) -> type[scan_directories.ScanInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
return scan_directories.ScanInfo
def get_gallery_data(self) -> list[scan_directories.ScanInfo]:
"""Return all the information from the scan directories.
It is preferable to use the method rather than calling
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read.
"""
ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
@ -547,16 +567,6 @@ class SmartScanThing(OFMThing):
stitch_automatically: bool = lt.setting(default=True)
"""Whether to run a final stitch at the end of a successful scan."""
def _get_all_scan_info(self) -> list[scan_directories.ScanInfo]:
"""Return all the information from the scan directories.
It is preferable to use the method rather than calling
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read.
"""
ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
@lt.property
def scans(self) -> ScanListInfo:
"""All the available scans.
@ -568,7 +578,7 @@ class SmartScanThing(OFMThing):
break it.
"""
return ScanListInfo(
scans=self._get_all_scan_info(),
scans=self.get_gallery_data(),
ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name,
)

View file

@ -1,5 +1,81 @@
"""Tests that captures have the expected metadata."""
import logging
from pydantic import BaseModel
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things import OFMThing
from openflexure_microscope_server.things.gallery import (
GalleryCompatibleThing,
GalleryThing,
)
from ..shared_utils.lt_test_utils import LabThingsTestEnv
class MockGalleryData(BaseModel):
"""Mock gallery data."""
mock: str
foobar: int
class MinimalGalleryClass:
"""A class that provides data to the gallery needs but is NOT a thing.
This should fail the protocol check as it is not an OFMThing
"""
gallery_data_name: str = "Mock"
gallery_data_schema: type[BaseModel] = MockGalleryData
def get_gallery_data(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""Minimal example of a Thing that can show in the gallery.
Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol.
"""
show_in_gallery = True
class BadGallerySource(OFMThing):
"""An OFMThing that almost defines enough to show in the gallery.
Shouldn't math the protocol as ``gallery_data_name`` is missing.
"""
show_in_gallery = True
gallery_data_schema: type[BaseModel] = MockGalleryData
def get_gallery_data(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only."""
# Minimal class has the gallery specific methods but is not an OFMThing.
assert not isinstance(MinimalGalleryClass(), GalleryCompatibleThing)
# BadGallerySource is an OFM Thing but is missing one of the required properties.
assert not isinstance(
create_thing_without_server(BadGallerySource), GalleryCompatibleThing
)
# MinimalGallerySource should match!
assert isinstance(
create_thing_without_server(MinimalGallerySource), GalleryCompatibleThing
)
def test_gallery_thing_finds_all_providers(simulation_test_env):
"""Check the gallery identifies the correct Things that will provide data."""
@ -15,3 +91,32 @@ def test_gallery_thing_finds_all_providers(simulation_test_env):
assert snake_workflow not in gallery.gallery_providing_things.values()
assert camera not in gallery.gallery_providing_things.values()
assert smart_scan in gallery.gallery_providing_things.values()
assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing)
def test_gallery_logs_for_bad_providers(caplog):
"""Test that an error is logged if a gallery source doesn't match the protocol."""
# Create a config with the minimal and the bad thing
things = {
"minimal_thing": MinimalGallerySource,
"bad_thing": BadGallerySource,
"gallery": GalleryThing,
}
with caplog.at_level(logging.INFO):
# Start the server
with LabThingsTestEnv(
things=things, application_config={"data_folder": "./openflexure/data/"}
) as env:
gallery = env.get_thing_by_name("gallery")
# Minimal thing is listed as a gallery provider
assert "minimal_thing" in gallery.gallery_providing_things
# Bad thing isn't as the protocol doesn't match
assert "bad_thing" not in gallery.gallery_providing_things
# There is an error logged about bad_thing
assert len(caplog.records) == 1
record = caplog.records[0]
assert "Data from bad_thing cannot be shown in gallery" in record.message
assert record.levelname == "ERROR"