diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index e8767928..e83c8f4f 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -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 diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 1e2ae5d5..3a3fdc28 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -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, ) diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 176be45f..f2b239d6 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -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"