Merge branch 'update-setting-manager' into 'v3'

Update setting manager

Closes #477, #343, and #475

See merge request openflexure/openflexure-microscope-server!321
This commit is contained in:
Julian Stirling 2025-07-14 16:47:41 +00:00
commit bed59c051b
6 changed files with 493 additions and 96 deletions

View file

@ -7,40 +7,12 @@ the server.
from collections.abc import Mapping
from socket import gethostname
from typing import Annotated, Any, MutableMapping, Optional, Sequence
from typing import Optional
from uuid import UUID, uuid4
from fastapi import Depends, HTTPException, Request
import labthings_fastapi as lt
def thing_server_from_request(request: Request) -> lt.ThingServer:
"""Wrap lt.find_thing_server."""
return lt.find_thing_server(request.app)
ThingServerDep = Annotated[lt.ThingServer, Depends(thing_server_from_request)]
def recursive_update(old_dict: MutableMapping, update: Mapping):
"""Update a dictionary recursively."""
for k, v in update.items():
if isinstance(v, Mapping):
if k in old_dict and isinstance(old_dict[k], MutableMapping):
recursive_update(old_dict[k], v)
else:
old_dict[k] = dict(**v)
else:
old_dict[k] = v
def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any:
"""Index a dict-of-dicts with a sequence of strings."""
subdict = data
for k in key:
if k not in subdict and create:
subdict[k] = {}
subdict = subdict[k]
return subdict
from openflexure_microscope_server.utilities import VersionData, robust_version_strings
class SettingsManager(lt.Thing):
@ -50,59 +22,6 @@ class SettingsManager(lt.Thing):
and the state of other Things.
"""
external_metadata = lt.ThingSetting(
initial_value={},
model=Mapping,
)
"""External metadata stored in the server's settings."""
external_metadata_in_state = lt.ThingSetting(
initial_value=[],
model=Sequence[str],
)
"""Keys from ``external_metadata`` that are included in the "state" metadata."""
@lt.thing_action
def update_external_metadata(
self, data: Mapping, key: Optional[str] = None
) -> None:
"""Add or replace keys in the external metadata.
The data supplied will be merged into the existing dictionary
recursively, i.e. if a key exists, it will be added to rather than
replaced.
If a key is supplied, we will treat ``data`` as being relative to that
key, i.e. calling this action with ``data={"a":1 }, key="foo/bar"`` is
equivalent to calling it with ``data={"foo": {"bar": {"a": 1}}}``.
"""
metadata = self.external_metadata
subdict = metadata
# If we're operating only on a part of the dict, make sure
# that key exists, and find it.
if key:
subdict = nested_dict_get(subdict, key.split("/"), create=True)
recursive_update(subdict, data)
self.external_metadata = metadata
@lt.thing_action
def delete_external_metadata(self, key: str) -> None:
"""Delete a key from the stored metadata.
The key may contain forward slashes, which are understood to separate
levels of the dictionary - i.e. ``'a/c'`` will remove the ``c`` key from a
dictionary that looks like: ``{'a': {'c': 1}, 'b': {'d': 2}}``
"""
metadata = self.external_metadata
try:
subdict = nested_dict_get(metadata, key.split("/")[:-1])
del subdict[key.split("/")[-1]]
except KeyError:
raise HTTPException(
status_code=404, detail="The specified key '{key}' was not found"
)
self.external_metadata = metadata
_microscope_id: Optional[str] = None
@lt.thing_setting
@ -122,6 +41,23 @@ class SettingsManager(lt.Thing):
"""The hostname of the microscope, as reported by its operating system."""
return gethostname()
_version_data: Optional[VersionData] = None
@lt.thing_property
def version_data(self) -> VersionData:
"""The version string and version source for the server.
The source may be a commit hash if installed from git. "TOML" if installed from
source, or "Dist" if installed from a distribution package.
If the version or its source cannot be determined an error will be Logged.
"""
# Don't save as a setting as it may change. Get the version string the first
# time the property is requested this boot.
if self._version_data is None:
self._version_data = robust_version_strings()
return self._version_data
@lt.thing_action
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
"""Metadata summarising the current state of all Things in the server."""
@ -130,17 +66,9 @@ class SettingsManager(lt.Thing):
@property
def thing_state(self) -> Mapping:
"""Summary metadata describing the current state of the Thing."""
state = {
return {
"hostname": self.hostname,
"microscope-uuid": str(self.microscope_id),
"version": self.version_data.version,
"version_source": self.version_data.version_source,
}
metadata = self.external_metadata
for k in self.external_metadata_in_state:
try:
kl = k.split("/")
v = nested_dict_get(metadata, kl)
subdict = nested_dict_get(state, kl[:-1], create=True)
subdict[kl[-1]] = v
except KeyError:
continue
return state

View file

@ -1,6 +1,21 @@
"""Utility functions and classes."""
import os
import re
from threading import Thread
import logging
from importlib.metadata import version
import tomllib
from pydantic import BaseModel
LOGGER = logging.getLogger(__name__)
REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# Regex for a full commit hash
COMMIT_REGEX = re.compile(r"[0-9a-f]{40}")
# Regex for a reference in the git HEAD file. Group 1 is the path.
REF_REGEX = re.compile(r"^ref:\s(.*)$")
class ErrorCapturingThread(Thread):
@ -74,3 +89,147 @@ def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs):
target(*args, **kwargs)
except BaseException as e:
error_buffer.append(e)
class VersionData(BaseModel):
"""A BaseModel containing the information about the server version.
:param version: The version string for the server. Or "Undefined" if there is an
error.
:param version_source: Either a Git commit hash, "TOML", "Dist" or in the case of
an error - "Undefined".
"""
version: str
version_source: str
def robust_version_strings() -> VersionData:
"""Return a version string and information on its source.
Returning a python version string is not without problems. If the package is
installed in an editable way, often METADATA is never updated. This provides 4
ways to provide a version string:
1. If both a Git directory and a pyproject.toml are available. Return the version
string from the toml file and the git hash from the ``.git`` directory as its
source.
2. If a pyproject.toml is available but no Git directory then this is likely an
installation from source. Return the version string from the toml file and
return the literal string "TOML" as its source.
3. If a Git directory is available but no pyproject.toml then something is very
weird - log an error. Then return "Undefined" as the version string, but still
return the Git hash as the source.
4. Finally if neither a Git directory nor a pyproject.toml are available then the
server has been installed from a distribution package (i.e. a wheel). In this
case use the standard library function ``importlib.metadata.version`` for the
version string (prepending a "v") and return "Dist" as its source.
:returns: a VersionData instance with the version string and source.
"""
project_toml_path = os.path.join(REPO_DIR, "pyproject.toml")
git_dir_path = os.path.join(REPO_DIR, ".git")
has_project_toml = os.path.isfile(project_toml_path)
has_git_dir = os.path.isdir(git_dir_path)
if has_project_toml and has_git_dir:
# .git dir and pyproject.toml available. This is a development
# installation.
ver = "v" + _get_version_from_toml(project_toml_path)
source = _get_hash_from_git_dir(git_dir_path)
elif has_project_toml:
# Only pyproject.toml available. Likely installed from source. Check
# file directly as `importlib.metadata.version` plays badly with editable
# installs.
ver = "v" + _get_version_from_toml(project_toml_path)
source = "TOML"
elif has_git_dir:
# Only .git dir, that is weird.
LOGGER.error(
"Unexpected installation configuration. Version number cannot be verified."
)
ver = "Undefined"
source = _get_hash_from_git_dir(git_dir_path)
else:
# Has neither .git nor pyproject.toml. It is probably installed from wheel.
# This will be the expected method of packaging in the future. This option
# is handled last as ``importlib.metadata.version`` can be unreliable if
# the package is no installed as a distribution package.
ver = "v" + version("openflexure_microscope_server")
source = "Dist"
return VersionData(version=ver, version_source=source)
def _get_hash_from_git_dir(git_dir_path: str) -> str:
"""Get the commit hash from a .git directory directly.
This function doesn't rely on GIT being on the PATH or even installed. It doesn't
require any packages outside the Python standard library. It directly inspects the
.git directory to get the commit hash:
* If the repository is on a detached HEAD then the hash will be in the file
.git/HEAD. A detached HEAD is could be from checking out a tag or checking out
a commit directly.
* In normal operation .git/HEAD points to a reference or "ref". This ref file
contains the hash.
:param git_dir_path: The path to the .git directory.
:returns: The hash, or "Undefined" if there is any error, errors are logged not
raised.
"""
head_path = os.path.join(git_dir_path, "HEAD")
try:
with open(head_path, "r", encoding="utf-8") as head_file:
head = head_file.read()
except IOError:
LOGGER.error("Problem opening .git/HEAD")
return "Undefined"
# The file HEAD should either be a reference or commit ID.
if match := COMMIT_REGEX.match(head):
return match.group(0)
if match := REF_REGEX.match(head):
ref_path = os.path.join(git_dir_path, match.group(1))
return _get_hash_from_git_ref(ref_path)
LOGGER.error("Unexpected format for .git/HEAD")
return "Undefined"
def _get_hash_from_git_ref(git_ref_path: str) -> str:
"""Get the commit hash from a ref in the .git directory.
For more detail see `_get_hash_from_git_dir`
:param git_ref_path: The full path to the ref file in the .git directory.
:returns: The hash, or "Undefined" if there is any error, errors are logged not
raised.
"""
try:
with open(git_ref_path, "r", encoding="utf-8") as ref_file:
ref = ref_file.read()
except IOError:
LOGGER.error("Problem opening Git branch reference.")
return "Undefined"
if ref_match := COMMIT_REGEX.match(ref):
return ref_match.group(0)
LOGGER.error("No hash found in Git ref")
return "Undefined"
def _get_version_from_toml(toml_path: str) -> str:
"""Get the version string from a pyproject.toml file.
:param toml_path: The path to the toml file.
:returns: The version string directly as reported in the toml file, or "Undefined"
if there is any error, errors are logged not raised.
"""
try:
with open(toml_path, "rb") as toml_file:
toml_dict = tomllib.load(toml_file)
return toml_dict["project"]["version"]
except (IOError, ValueError, KeyError):
LOGGER.error("Problem opening pyproject.toml")
return "Undefined"