openflexure-microscope-server/src/openflexure_microscope_server/things/__init__.py
Julian Stirling 071d62e8a0 Apply suggestions from code review of branch camera_modes
Co-authored-by: Joe Knapper <joe.knapper@glasgow.ac.uk>
2026-06-05 16:58:35 +01:00

161 lines
5.8 KiB
Python

"""A package for all of the core LabThings-FastAPI Things shipped with the microscope.
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.
"""
import os
from pathlib import PurePath
from tempfile import TemporaryDirectory
from types import TracebackType
from typing import Optional, Self
from pydantic import PrivateAttr, RootModel, model_validator
import labthings_fastapi as lt
class OFMThing(lt.Thing):
"""A custom LabThings Thing class for the OpenFlexure Microscope."""
_data_dir: Optional[str] = None
_show_data_in_gallery: bool = False
@property
def show_data_in_gallery(self) -> bool:
"""Whether to show in the Gallery."""
return self._show_data_in_gallery
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 = os.path.join(
os.path.normpath(str(app_data_dir)), os.path.normpath(self.name)
)
if not os.path.exists(self.data_dir):
os.makedirs(self.data_dir)
return self
def __exit__(
self,
_exc_type: type[BaseException],
_exc_value: Optional[BaseException],
_traceback: Optional[TracebackType],
) -> None:
"""Close the OFMThing.
This is needed for the context manager protocol to work. Currently it doesn't
do anything.
"""
pass
@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
def create_data_path(self, path: str, absolute: bool = False) -> "RelativeDataPath":
"""Create a ``RelativeDataPath`` object with this Thing set as the saving Thing.
:param path: The relative path within the data directory of this Thing's data
dir that the data should be saved to.
:param absolute: Set to True if the current path is absolute. A relative path
will be returned. A validation error will be raised if the absolute path
is not within the data directory.
:return: A ``RelativeDataPath`` object with the saving Thing already set.
"""
if absolute:
path = os.path.relpath(path, self.data_dir)
rel_data_path = RelativeDataPath(path)
rel_data_path.set_saving_thing(self)
return rel_data_path
class RelativeDataPath(RootModel[str]):
"""A relative path that is validated, and can have a Thing assigned to it.
Use ``set_saving_thing`` or ``set_saving_thing_if_unset`` to set the Thing whose
data directory will be used for the final save.
Use the ``abs_data_path`` property to get the final path for saving.
"""
_saving_thing: Optional[OFMThing | TemporaryDirectory] = PrivateAttr(default=None)
@model_validator(mode="before")
@classmethod
def validate_relative_path(cls, value: str) -> str:
"""Validate the relative path is relative and has no parent dir references."""
p = PurePath(value)
if p.is_absolute():
raise ValueError("Absolute paths are not allowed")
if ".." in p.parts:
raise ValueError("Parent directory references are not allowed")
return os.path.normpath(value)
@property
def save_location_set(self) -> bool:
"""Return True if the saving thing is set."""
return self._saving_thing is not None
def set_saving_thing(self, thing: OFMThing | TemporaryDirectory) -> None:
"""Set the Thing that is saving the data.
The thing can also be a ``TemporaryDirectory`` object.
This will set the data directory.
"""
if self.save_location_set:
raise RuntimeError("The saving Thing for the relative path is already set")
self._saving_thing = thing
def set_saving_thing_if_unset(self, thing: OFMThing) -> None:
"""Set the Thing that is saving the data if it is not already set.
Use this in an action to set the Thing for paths set via the API.
"""
if not self.save_location_set:
self._saving_thing = thing
def save_to_tempdir(self) -> TemporaryDirectory:
"""Use a temporary directory to save raher than an ``OFMThing``.
:returns: the ``TemporaryDirectory`` object.
"""
if self.save_location_set:
raise RuntimeError("The saving Thing for the relative path is already set")
self._saving_thing = TemporaryDirectory()
return self._saving_thing
def join(self, sub_path: str) -> "RelativeDataPath":
"""Join a path to the end of this path.
:return: A new ``RelativeDataPath`` object with the path appended.
"""
new_path = RelativeDataPath(os.path.join(self.root, sub_path))
if self._saving_thing is not None:
new_path.set_saving_thing(self._saving_thing)
return new_path
@property
def abs_data_path(self) -> str:
"""The absolute data directory to save to."""
if self._saving_thing is None:
raise RuntimeError("The saving Thing for the relative path was never set")
if isinstance(self._saving_thing, TemporaryDirectory):
return os.path.join(self._saving_thing.name, self.root)
return os.path.join(self._saving_thing.data_dir, self.root)