Source code for magscope.settings

from __future__ import annotations

import copy
import os
from dataclasses import dataclass
from typing import Any, Callable, Iterable, Mapping, MutableMapping

from PyQt6.QtCore import QSettings
import yaml


@dataclass(frozen=True)
[docs] class SettingSpec:
[docs] key: str
[docs] value_type: type | tuple[type, ...]
[docs] default: Any | None = None
[docs] display_name: str | None = None
[docs] minimum: float | None = None
[docs] maximum: float | None = None
[docs] must_be_even: bool = False
[docs] def coerce(self, value: Any) -> Any: if isinstance(value, str): value = value.strip() if value == "": raise ValueError(f"Setting '{self.key}' cannot be empty") try: if float in self._candidate_types: coerced = float(value) else: coerced = int(value) except (TypeError, ValueError): coerced = value else: coerced = value if not isinstance(coerced, self.value_type): raise ValueError( f"Setting '{self.key}' must be of type {self.value_type}, not {type(coerced)}." ) if self.minimum is not None and isinstance(coerced, (int, float)): if coerced < self.minimum: raise ValueError( f"Setting '{self.key}' must be at least {self.minimum}, not {coerced}." ) if self.maximum is not None and isinstance(coerced, (int, float)): if coerced > self.maximum: raise ValueError( f"Setting '{self.key}' must be at most {self.maximum}, not {coerced}." ) if self.must_be_even and isinstance(coerced, int): if coerced % 2 != 0: raise ValueError( f"Setting '{self.key}' must be an even integer, not {coerced}." ) return coerced
[docs] def default_value(self) -> Any: return copy.deepcopy(self.default)
@property
[docs] def _candidate_types(self) -> tuple[type, ...]: if isinstance(self.value_type, tuple): return self.value_type return (self.value_type,)
@property
[docs] def label(self) -> str: return self.display_name or self.key
[docs] class MagScopeSettings(MutableMapping[str, Any]):
[docs] _QSETTINGS_ORGANIZATION = "MagScope"
[docs] _QSETTINGS_APPLICATION = "MagScope"
[docs] _QSETTINGS_GROUP = "MagScopeSettings"
[docs] _SETTING_SPECS: dict[str, SettingSpec] = { "ROI": SettingSpec( "ROI", value_type=int, default=50, display_name="ROI (pixels)", minimum=8, maximum=256, must_be_even=True, ), "magnification": SettingSpec( "magnification", value_type=(int, float), default=1, display_name="Magnification (x)", minimum=1, ), "tracks max datapoints": SettingSpec( "tracks max datapoints", value_type=int, default=1_000_000, display_name="Tracks max datapoints", minimum=1, ), "video buffer n images": SettingSpec( "video buffer n images", value_type=int, default=40, display_name="Video buffer n images", minimum=1, ), "video buffer n stacks": SettingSpec( "video buffer n stacks", value_type=int, default=5, display_name="Video buffer n stacks", minimum=1, ), "video processors n": SettingSpec( "video processors n", value_type=int, default=3, display_name="Video processors n", minimum=1, ), "xy-lock default interval": SettingSpec( "xy-lock default interval", value_type=(int, float), default=10, display_name="XY-lock default interval", minimum=0, ), "xy-lock default max": SettingSpec( "xy-lock default max", value_type=(int, float), default=10, display_name="XY-lock default max", minimum=0, ), "xy-lock default window": SettingSpec( "xy-lock default window", value_type=int, default=10, display_name="XY-lock default window", minimum=1, ), "z-lock default interval": SettingSpec( "z-lock default interval", value_type=(int, float), default=10, display_name="Z-lock default interval", minimum=0, ), "z-lock default max": SettingSpec( "z-lock default max", value_type=(int, float), default=1_000, display_name="Z-lock default max", minimum=0, ), "z-lock default window": SettingSpec( "z-lock default window", value_type=int, default=10, display_name="Z-lock default window", minimum=1, ), }
def __init__( self, values: Mapping[str, Any] | None = None, *, persistence_available: bool = True, persistence_enabled: bool = False, ):
[docs] self._persistence_enabled = persistence_enabled
[docs] self._persistence_listeners: list[Callable[["MagScopeSettings"], None]] = []
[docs] self.persistence_available = persistence_available
[docs] self._values: dict[str, Any] = {}
self.update(self._load_defaults()) if values: self.update(values) @classmethod
[docs] def _load_defaults(cls) -> dict[str, Any]: return {key: spec.default_value() for key, spec in cls._SETTING_SPECS.items()}
@classmethod
[docs] def _qsettings(cls) -> QSettings: return QSettings(cls._QSETTINGS_ORGANIZATION, cls._QSETTINGS_APPLICATION)
[docs] def _load_qsettings_values(self) -> dict[str, Any]: settings = self._qsettings() settings.beginGroup(self._QSETTINGS_GROUP) loaded: dict[str, Any] = {} for key, spec in self._SETTING_SPECS.items(): if not settings.contains(key): continue raw_value = settings.value(key) try: loaded[key] = spec.coerce(raw_value) except ValueError: continue settings.endGroup() self._update_persistence_availability( settings.isWritable() and settings.status() == QSettings.Status.NoError ) return loaded
[docs] def _update_persistence_availability(self, available: bool) -> None: was_available = self.persistence_available self.persistence_available = available if was_available and not available: for listener in list(self._persistence_listeners): listener(self)
[docs] def add_persistence_listener( self, callback: Callable[["MagScopeSettings"], None] ) -> None: self._persistence_listeners.append(callback)
@classmethod
[docs] def from_qsettings( cls, values: Mapping[str, Any] | None = None ) -> "MagScopeSettings": settings = cls(persistence_enabled=True) settings.update(settings._load_qsettings_values()) if values: settings.update(values) return settings
[docs] def save_to_qsettings(self) -> None: if not self._persistence_enabled: return settings = self._qsettings() settings.beginGroup(self._QSETTINGS_GROUP) settings.remove("") for key, value in self._values.items(): settings.setValue(key, value) settings.endGroup() settings.sync() self._update_persistence_availability( settings.isWritable() and settings.status() == QSettings.Status.NoError )
[docs] def reset_to_defaults(self) -> None: self._values = {} self.update(self._load_defaults())
[docs] def to_dict(self) -> dict[str, Any]: return copy.deepcopy(self._values)
[docs] def clone(self) -> "MagScopeSettings": return MagScopeSettings( self._values, persistence_available=self.persistence_available, )
[docs] def persistent_copy(self) -> "MagScopeSettings": return MagScopeSettings( self._values, persistence_available=self.persistence_available, persistence_enabled=True, )
[docs] def _coerce_setting(self, key: str, value: Any) -> Any: if key not in self._SETTING_SPECS: raise KeyError(f"Unknown setting '{key}'.") spec = self._SETTING_SPECS[key] return spec.coerce(value)
[docs] def update(self, mapping: Mapping[str, Any] | Iterable[tuple[str, Any]] = (), **kwargs: Any) -> None: # type: ignore[override] items: Iterable[tuple[str, Any]] if isinstance(mapping, Mapping): items = mapping.items() else: items = mapping for key, value in items: self[key] = value for key, value in kwargs.items(): self[key] = value
[docs] def __getitem__(self, key: str) -> Any: return self._values[key]
[docs] def __setitem__(self, key: str, value: Any) -> None: coerced = self._coerce_setting(key, value) self._values[key] = coerced
[docs] def __delitem__(self, key: str) -> None: raise TypeError("MagScopeSettings does not support deleting settings")
[docs] def __iter__(self): return iter(self._values)
[docs] def __len__(self): return len(self._values)
[docs] def export_yaml(self, path: str | os.PathLike[str]) -> None: with open(path, "w", encoding="utf-8") as file: yaml.safe_dump(self._values, file)
@classmethod
[docs] def import_yaml(cls, path: str | os.PathLike[str]) -> "MagScopeSettings": with open(path, "r", encoding="utf-8") as file: data = yaml.safe_load(file) if data is None: raise ValueError(f"Settings file {path} is empty") if not isinstance(data, dict): raise ValueError(f"Settings file {path} must be a YAML mapping") return cls(data)
@classmethod
[docs] def spec_for(cls, key: str) -> SettingSpec: return cls._SETTING_SPECS[key]
@classmethod
[docs] def defined_keys(cls) -> Iterable[str]: return cls._SETTING_SPECS.keys()