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:
commit
bed59c051b
6 changed files with 493 additions and 96 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue