Merge branch 'start-to-remove-deps' into 'v3'

Start to remove deps

See merge request openflexure/openflexure-microscope-server!451
This commit is contained in:
Julian Stirling 2025-12-16 16:23:28 +00:00
commit b5fdc0778d
8 changed files with 31 additions and 140 deletions

Binary file not shown.

View file

@ -10,7 +10,6 @@ from __future__ import annotations
import io import io
import json import json
import logging
import os import os
import tempfile import tempfile
import time import time
@ -34,8 +33,6 @@ from openflexure_microscope_server.background_detect import (
) )
from openflexure_microscope_server.ui import ActionButton, PropertyControl from openflexure_microscope_server.ui import ActionButton, PropertyControl
LOGGER = logging.getLogger(__name__)
class JPEGBlob(lt.blob.Blob): class JPEGBlob(lt.blob.Blob):
"""A class representing a JPEG image as a LabThings FastAPI Blob.""" """A class representing a JPEG image as a LabThings FastAPI Blob."""
@ -282,8 +279,6 @@ class BaseCamera(lt.Thing):
@lt.action @lt.action
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: lt.deps.GetThingStates,
logger: lt.deps.InvocationLogger,
stream_name: str = "main", stream_name: str = "main",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> JPEGBlob: ) -> JPEGBlob:
@ -292,9 +287,6 @@ class BaseCamera(lt.Thing):
This will use the internal capture image functionally of capture_image of This will use the internal capture image functionally of capture_image of
the specific camera being used. the specific camera being used.
:param metadata_getter: LabThings GetThingStates dependency, automatically
injected.
:param logger: LabThings InvocationLogger dependency, automatically injected.
:param stream_name: A stream name supported by this camera. :param stream_name: A stream name supported by this camera.
:param wait: (Optional, float) Set a timeout in seconds. If None it will :param wait: (Optional, float) Set a timeout in seconds. If None it will
use the default for the underlying camera. use the default for the underlying camera.
@ -305,13 +297,12 @@ class BaseCamera(lt.Thing):
img = self.capture_image(stream_name, wait) img = self.capture_image(stream_name, wait)
capture_metadata = self._capture_metadata(metadata_getter()) capture_metadata = self._capture_metadata()
self._save_capture( self._save_capture(
jpeg_path=jpeg_path, jpeg_path=jpeg_path,
image=img, image=img,
metadata=capture_metadata, metadata=capture_metadata,
logger=logger,
) )
return JPEGBlob.from_temporary_directory(directory, fname) return JPEGBlob.from_temporary_directory(directory, fname)
@ -390,70 +381,43 @@ class BaseCamera(lt.Thing):
def capture_and_save( def capture_and_save(
self, self,
jpeg_path: str, jpeg_path: str,
logger: lt.deps.InvocationLogger,
metadata_getter: lt.deps.GetThingStates,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Capture an image and save it to disk. """Capture an image and save it to disk.
:param jpeg_path: The path to save the file to :param jpeg_path: The path to save the file to
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
:param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action
:param save_resolution: can be set to resize the image before saving. By :param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original resolution. default this is None meaning that the image is saved at original resolution.
""" """
image, capture_metadata = self._robust_image_capture( image, capture_metadata = self._robust_image_capture()
metadata_getter,
logger=logger,
)
self._save_capture( self._save_capture(jpeg_path, image, capture_metadata, save_resolution)
jpeg_path,
image,
capture_metadata,
logger,
save_resolution,
)
@lt.action @lt.action
def capture_to_memory( def capture_to_memory(self, buffer_max: int = 1) -> int:
self,
logger: lt.deps.InvocationLogger,
metadata_getter: lt.deps.GetThingStates,
buffer_max: int = 1,
) -> int:
"""Capture an image to memory. This can be saved later with ``save_from_memory``. """Capture an image to memory. This can be saved later with ``save_from_memory``.
Note that only one image is held in memory so this will overwrite any image Note that only one image is held in memory so this will overwrite any image
in memory. in memory.
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
:param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action
:param buffer_max: The maximum number of images that should be in the buffer :param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1. once this images is added. Default is 1.
:returns: the buffer id of the image captured :returns: the buffer id of the image captured
""" """
image, metadata = self._robust_image_capture(metadata_getter, logger) image, metadata = self._robust_image_capture()
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@lt.action @lt.action
def save_from_memory( def save_from_memory(
self, self,
jpeg_path: str, jpeg_path: str,
logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
buffer_id: Optional[int] = None, buffer_id: Optional[int] = None,
) -> None: ) -> None:
"""Save an image that has been captured to memory. """Save an image that has been captured to memory.
:param jpeg_path: The path to save the file to :param jpeg_path: The path to save the file to
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
:param save_resolution: can be set to resize the image before saving. By :param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original default this is None meaning that the image is saved at original
resolution. resolution.
@ -466,7 +430,6 @@ class BaseCamera(lt.Thing):
jpeg_path=jpeg_path, jpeg_path=jpeg_path,
image=image, image=image,
metadata=metadata, metadata=metadata,
logger=logger,
save_resolution=save_resolution, save_resolution=save_resolution,
) )
@ -475,11 +438,7 @@ class BaseCamera(lt.Thing):
"""Clear all images in memory.""" """Clear all images in memory."""
self._memory_buffer.clear() self._memory_buffer.clear()
def _robust_image_capture( def _robust_image_capture(self) -> Tuple[Image, Mapping[str, Any]]:
self,
metadata_getter: lt.deps.GetThingStates,
logger: lt.deps.InvocationLogger,
) -> Tuple[Image, Mapping[str, Any]]:
"""Capture an image in memory and return it with metadata. """Capture an image in memory and return it with metadata.
This robust capturing method attempts to capture the image five times This robust capturing method attempts to capture the image five times
@ -491,22 +450,19 @@ class BaseCamera(lt.Thing):
""" """
for capture_attempts in range(5): for capture_attempts in range(5):
try: try:
metadata = metadata_getter() capture_metadata = self._capture_metadata()
capture_metadata = self._capture_metadata(metadata)
image = self.capture_image(stream_name="main", wait=5) image = self.capture_image(stream_name="main", wait=5)
return image, capture_metadata return image, capture_metadata
except TimeoutError: except TimeoutError:
logger.warning( self.logger.warning(
f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?" f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?"
) )
raise CaptureError("An error occurred while capturing after 5 attempts") raise CaptureError("An error occurred while capturing after 5 attempts")
def _capture_metadata( def _capture_metadata(self) -> dict:
self,
metadata: Mapping[str, Any],
) -> dict:
"""Return the metadata for a capture, from the thing states, time and known names.""" """Return the metadata for a capture, from the thing states, time and known names."""
metadata = self._thing_server_interface.get_thing_states()
current_time = datetime.now() current_time = datetime.now()
return { return {
"capture_time": current_time.timestamp(), "capture_time": current_time.timestamp(),
@ -566,12 +522,11 @@ class BaseCamera(lt.Thing):
jpeg_path: str, jpeg_path: str,
image: Image, image: Image,
metadata: dict, metadata: dict,
logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Save the captured image and metadata to disk. """Save the captured image and metadata to disk.
A warning (via InvocationLogger) is raised if metadata is failed to be added A warning is logged if metadata cannot be added.
:raises IOError: if the file cannot be saved :raises IOError: if the file cannot be saved
@ -593,7 +548,7 @@ class BaseCamera(lt.Thing):
except Exception: except Exception:
# We need to capture any exception as there are many reasons metadata # We need to capture any exception as there are many reasons metadata
# might not be added. We warn rather than log the error. # might not be added. We warn rather than log the error.
logger.exception(f"Failed to add metadata to {jpeg_path}") self.logger.exception(f"Failed to add metadata to {jpeg_path}")
except Exception as e: except Exception as e:
raise IOError(f"An error occurred while saving {jpeg_path}") from e raise IOError(f"An error occurred while saving {jpeg_path}") from e
@ -639,7 +594,7 @@ class BaseCamera(lt.Thing):
def detector_name(self, name: str) -> None: def detector_name(self, name: str) -> None:
"""Validate and set detector_name.""" """Validate and set detector_name."""
if name not in self.background_detectors: if name not in self.background_detectors:
LOGGER.warning(f"{name} is not a valid background detector name.") self.logger.warning(f"{name} is not a valid background detector name.")
self._detector_name = name self._detector_name = name
@property @property
@ -697,7 +652,7 @@ class BaseCamera(lt.Thing):
obj.settings = instance_data["settings"] obj.settings = instance_data["settings"]
obj.background_data = instance_data["background_data"] obj.background_data = instance_data["background_data"]
else: else:
LOGGER.warning( self.logger.warning(
f"No background detector named {name}, settings will be discarded." f"No background detector named {name}, settings will be discarded."
) )

View file

@ -133,24 +133,15 @@ class BaseStage(lt.Thing):
self.axis_inverted = direction self.axis_inverted = direction
@lt.action @lt.action
def move_relative( def move_relative(self, block_cancellation: bool = False, **kwargs: int) -> None:
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: int,
) -> None:
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
self._hardware_move_relative( self._hardware_move_relative(
cancel=cancel,
block_cancellation=block_cancellation, block_cancellation=block_cancellation,
**self._apply_axis_direction(kwargs), **self._apply_axis_direction(kwargs),
) )
def _hardware_move_relative( def _hardware_move_relative(
self, self, block_cancellation: bool = False, **kwargs: int
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: int,
) -> Never: ) -> Never:
"""Make a relative move in the coordinate system used by the physical hardware. """Make a relative move in the coordinate system used by the physical hardware.
@ -161,22 +152,15 @@ class BaseStage(lt.Thing):
) )
@lt.action @lt.action
def move_absolute( def move_absolute(self, block_cancellation: bool = False, **kwargs: int) -> None:
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: int,
) -> None:
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
self._hardware_move_absolute( self._hardware_move_absolute(
cancel=cancel,
block_cancellation=block_cancellation, block_cancellation=block_cancellation,
**self._apply_axis_direction(kwargs), **self._apply_axis_direction(kwargs),
) )
def _hardware_move_absolute( def _hardware_move_absolute(
self, self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> Never: ) -> Never:
@ -212,20 +196,16 @@ class BaseStage(lt.Thing):
return (position_dict["x"], position_dict["y"], position_dict["z"]) return (position_dict["x"], position_dict["y"], position_dict["z"])
@lt.action @lt.action
def move_to_xyz_position( def move_to_xyz_position(self, xyz_pos: tuple[int, int, int]) -> None:
self, cancel: lt.deps.CancelHook, xyz_pos: tuple[int, int, int]
) -> None:
"""Move to the location specified by an (x, y, z) tuple. """Move to the location specified by an (x, y, z) tuple.
:param cancel: A cancel hook for cancelling the move. This dependency should be
injected automatically by LabThings-FastAPI
:param xyz_pos: The (x, y, z) position to move to. :param xyz_pos: The (x, y, z) position to move to.
:raises KeyError: if this stage does not have axes named "x", "y", and "z". :raises KeyError: if this stage does not have axes named "x", "y", and "z".
This method provides the interface expected by the camera_stage_mapping. This method provides the interface expected by the camera_stage_mapping.
""" """
self.move_absolute(cancel=cancel, x=xyz_pos[0], y=xyz_pos[1], z=xyz_pos[2]) self.move_absolute(x=xyz_pos[0], y=xyz_pos[1], z=xyz_pos[2])
StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "stage") StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "stage")

View file

@ -55,7 +55,6 @@ class DummyStage(BaseStage):
def _hardware_move_relative( def _hardware_move_relative(
self, self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> None: ) -> None:
@ -71,7 +70,7 @@ class DummyStage(BaseStage):
if block_cancellation: if block_cancellation:
time.sleep(dt) time.sleep(dt)
else: else:
cancel.sleep(dt) lt.cancellable_sleep(dt)
fraction_complete = (time.time() - start_time) / (dt * max_displacement) fraction_complete = (time.time() - start_time) / (dt * max_displacement)
self.instantaneous_position = { self.instantaneous_position = {
ax: self._hardware_position[ax] + int(fraction_complete * disp) ax: self._hardware_position[ax] + int(fraction_complete * disp)
@ -93,7 +92,6 @@ class DummyStage(BaseStage):
def _hardware_move_absolute( def _hardware_move_absolute(
self, self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> None: ) -> None:
@ -104,7 +102,7 @@ class DummyStage(BaseStage):
if axis in self.axis_names if axis in self.axis_names
} }
self._hardware_move_relative( self._hardware_move_relative(
cancel, block_cancellation=block_cancellation, **displacement block_cancellation=block_cancellation, **displacement
) )
@lt.action @lt.action

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import threading import threading
import time import time
from collections.abc import Mapping from collections.abc import Mapping
@ -18,8 +17,6 @@ import sangaboard
from . import BaseStage from . import BaseStage
LOGGER = logging.getLogger(__name__)
REQUIRED_VERSION = semver.Version.parse("1.0.0") REQUIRED_VERSION = semver.Version.parse("1.0.0")
RECOMMENDED_VERSION = semver.Version.parse("1.0.4") RECOMMENDED_VERSION = semver.Version.parse("1.0.4")
@ -117,14 +114,13 @@ class SangaboardThing(BaseStage):
# Warn if version is below recommended # Warn if version is below recommended
if version < RECOMMENDED_VERSION: if version < RECOMMENDED_VERSION:
LOGGER.warning( self.logger.warning(
f"Sangaboard firmware version {version} is below the recommended " f"Sangaboard firmware version {version} is below the recommended "
f"{RECOMMENDED_VERSION}." f"{RECOMMENDED_VERSION}."
) )
def _hardware_move_relative( def _hardware_move_relative(
self, self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> None: ) -> None:
@ -138,7 +134,7 @@ class SangaboardThing(BaseStage):
sb.query("notify_on_stop") sb.query("notify_on_stop")
else: else:
while sb.query("moving?") == "true": while sb.query("moving?") == "true":
cancel.sleep(0.1) lt.cancellable_sleep(0.1)
except lt.exceptions.InvocationCancelledError as e: except lt.exceptions.InvocationCancelledError as e:
# If the move has been cancelled, stop it but don't handle the exception. # If the move has been cancelled, stop it but don't handle the exception.
# We need the exception to propagate in order to stop any calling tasks, # We need the exception to propagate in order to stop any calling tasks,
@ -151,7 +147,6 @@ class SangaboardThing(BaseStage):
def _hardware_move_absolute( def _hardware_move_absolute(
self, self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> None: ) -> None:
@ -164,7 +159,7 @@ class SangaboardThing(BaseStage):
if axis in self.axis_names if axis in self.axis_names
} }
self._hardware_move_relative( self._hardware_move_relative(
cancel, block_cancellation=block_cancellation, **displacement block_cancellation=block_cancellation, **displacement
) )
@lt.action @lt.action
@ -203,7 +198,7 @@ class SangaboardThing(BaseStage):
# cannot be used. # cannot be used.
intended_brightness = float(return_value[7:]) intended_brightness = float(return_value[7:])
on_brightness = 0.32 on_brightness = 0.32
LOGGER.warning( self.logger.warning(
"Brightness control is not yet implemented. Desired brightness: " "Brightness control is not yet implemented. Desired brightness: "
f"{intended_brightness}. Set brightness: {on_brightness}" f"{intended_brightness}. Set brightness: {on_brightness}"
) )

View file

@ -121,9 +121,9 @@ class OpenFlexureSystem(lt.Thing):
return CommandOutput(output=out, error=err) return CommandOutput(output=out, error=err)
@lt.action @lt.action
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping: def get_things_state(self) -> Mapping:
"""Metadata summarising the current state of all Things in the server.""" """Metadata summarising the current state of all Things in the server."""
return metadata_getter() return self._thing_server_interface.get_thing_states()
@property @property
def thing_state(self) -> Mapping[str, Any]: def thing_state(self) -> Mapping[str, Any]:

View file

@ -1,19 +0,0 @@
"""Create a fake CancelHook used instead of a dependency, it cannot be used to cancel!
Certain actions require a CancelHook to be supplied if called directly. The only method
we use from the LabThings CancelHook is ``sleep()``. The CanceHook ``sleep()`` works
exactly like ``time.sleep()`` except with raise an ``InvocationCancelledError`` if the
server receives a cancellation request.
This is just an object with a sleep method.
"""
import time
class MockCancel:
"""A class to use when directly calling an action with CancelHook dependency."""
def sleep(self, time_in_seconds: float) -> None:
"""Sleep for the input number of seconds."""
time.sleep(time_in_seconds)

View file

@ -18,8 +18,6 @@ from openflexure_microscope_server.things.stage import (
) )
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
from .mock_things.mock_cancel import MockCancel
# Keep the size and number of moves fairly small or the tests can take forever # Keep the size and number of moves fairly small or the tests can take forever
point3d = st.tuples( point3d = st.tuples(
st.integers(min_value=-100, max_value=100), st.integers(min_value=-100, max_value=100),
@ -60,12 +58,7 @@ def test_override_base_movement():
class BadStage1(BaseStage): class BadStage1(BaseStage):
@lt.action @lt.action
def move_relative( def move_relative(self, block_cancellation: bool = False, **kwargs: int):
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: int,
):
pass pass
with pytest.raises(RedefinedBaseMovementError): with pytest.raises(RedefinedBaseMovementError):
@ -73,12 +66,7 @@ def test_override_base_movement():
class BadStage2(BaseStage): class BadStage2(BaseStage):
@lt.action @lt.action
def move_absolute( def move_absolute(self, block_cancellation: bool = False, **kwargs: int):
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: int,
):
pass pass
with pytest.raises(RedefinedBaseMovementError): with pytest.raises(RedefinedBaseMovementError):
@ -200,7 +188,6 @@ def _test_move_relative(dummy_stage, axis_inverted, path):
:param axis_inverted: Is used to set the inversion. :param axis_inverted: Is used to set the inversion.
:param path: The 3d path to move over, generated by hypothesis. :param path: The 3d path to move over, generated by hypothesis.
""" """
cancel = MockCancel()
dummy_stage.axis_inverted = axis_inverted dummy_stage.axis_inverted = axis_inverted
# Explicitly do axes calculation here to check logic in main code. # Explicitly do axes calculation here to check logic in main code.
x_dir = -1 if axis_inverted["x"] else 1 x_dir = -1 if axis_inverted["x"] else 1
@ -209,9 +196,7 @@ def _test_move_relative(dummy_stage, axis_inverted, path):
position = list(dummy_stage.position.values()) position = list(dummy_stage.position.values())
for movement in path: for movement in path:
dummy_stage.move_relative( dummy_stage.move_relative(x=movement[0], y=movement[1], z=movement[2])
cancel=cancel, x=movement[0], y=movement[1], z=movement[2]
)
position = [pos + move for pos, move in zip(position, movement, strict=True)] position = [pos + move for pos, move in zip(position, movement, strict=True)]
stage_pos = dummy_stage.get_xyz_position() stage_pos = dummy_stage.get_xyz_position()
hw_pos = dummy_stage._hardware_position hw_pos = dummy_stage._hardware_position
@ -257,7 +242,6 @@ def _test_move_absolute(dummy_stage, axis_inverted, path):
:param axis_inverted: Is used to set the inversion. :param axis_inverted: Is used to set the inversion.
:param path: The 3d path to move over, generated by hypothesis. :param path: The 3d path to move over, generated by hypothesis.
""" """
cancel = MockCancel()
dummy_stage.axis_inverted = axis_inverted dummy_stage.axis_inverted = axis_inverted
# Explicitly do axes calculation here to check logic in main code. # Explicitly do axes calculation here to check logic in main code.
x_dir = -1 if axis_inverted["x"] else 1 x_dir = -1 if axis_inverted["x"] else 1
@ -265,9 +249,7 @@ def _test_move_absolute(dummy_stage, axis_inverted, path):
z_dir = -1 if axis_inverted["z"] else 1 z_dir = -1 if axis_inverted["z"] else 1
position = list(dummy_stage.position.values()) position = list(dummy_stage.position.values())
for move_to in path: for move_to in path:
dummy_stage.move_absolute( dummy_stage.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2])
cancel=cancel, x=move_to[0], y=move_to[1], z=move_to[2]
)
position = move_to position = move_to
stage_pos = dummy_stage.get_xyz_position() stage_pos = dummy_stage.get_xyz_position()
hw_pos = dummy_stage._hardware_position hw_pos = dummy_stage._hardware_position