# Profile settings
# Author: David Young, 2019, 2020
"""Profile settings to setup common configurations.
Each profile has a default set of settings, which can be modified through
"modifier" sub-profiles with groups of settings that overwrite the
given default settings.
"""
import dataclasses
from enum import Enum, auto
import glob
import os
import pprint
from typing import Dict, Optional, TYPE_CHECKING, Union
from magmap.io import yaml_io
from magmap.settings import config
if TYPE_CHECKING:
import pathlib
_logger = config.logger.getChild(__name__)
[docs]
class RegKeys(Enum):
"""Register setting enumerations."""
ACTIVE = auto()
MARKER_EROSION = auto()
MARKER_EROSION_MIN = auto()
MARKER_EROSION_USE_MIN = auto()
SKELETON_EROSION = auto()
WATERSHED_MASK_FILTER = auto()
SAVE_STEPS = auto()
EDGE_AWARE_REANNOTATION = auto()
METRICS_CLUSTER = auto()
DBSCAN_EPS = auto()
DBSCAN_MINPTS = auto()
KNN_N = auto()
#: dict: Dictionary mapping the names of Enums used in profiles to their Enum
# classes for parsing Enums given as strings.
_PROFILE_ENUMS = {
"RegKeys": RegKeys,
"Cmaps": config.Cmaps,
"SmoothingModes": config.SmoothingModes,
"MetricGroups": config.MetricGroups,
"LoadIO": config.LoadIO,
}
# TODO: completely migrate from dict to dataclass
[docs]
@dataclasses.dataclass(repr=False)
class SettingsDict(dict):
"""Profile dictionary, which contains collections of settings.
Allows modification by applying additional groups of settings specified in
this dictionary. Supports saving and loading settings from a YAML file.
This class is being migrated to a data class, which supports type hints
and attribute access. It will also reduce the need for Enums as keys.
Attributes:
PATH_PROFILES (str): Path to profiles directory.
NAME_KEY (str): Key for profile name.
DEFAULT_NAME (str): Default profile modifier name.
profiles (dict): Dictionary of profiles to modify the default
values, where each key is the profile name and the value
is a nested dictionary that will overwrite or update the
current values.
timestamps (dict): Dictionary of profile files to last modified time.
delimiter (str): Profile names delimiter; defaults to ``,``.
"""
PATH_PROFILES = "profiles"
_EXT_YAML = (".yml", ".yaml")
NAME_KEY = "settings_name"
DEFAULT_NAME = "default"
def __init__(self, *args, **kwargs):
"""Initialize a settings dictionary.
Args:
*args:
**kwargs:
"""
super().__init__(self)
self[self.NAME_KEY] = "default"
self.profiles: Dict[str, Dict] = {}
self.timestamps = {}
self.delimiter = ","
# update with args
self.update(*args, **kwargs)
[docs]
def __repr__(self):
"""Represent with dict items and data class attributes."""
return f"{super().__repr__()}\n{dataclasses.asdict(self)}"
[docs]
@staticmethod
def get_files(profiles_dir=None, filename_prefix=""):
"""Get profile files.
Args:
profiles_dir (str): Directory from which to get files; defaults
to None to use :const:`PATH_PROFILES`.
filename_prefix (str): Only get files starting with this string;
defaults to an empty string.
Returns:
List[str]: List of files in ``profiles_dir`` matching the given
``filename_prefix`` and ending with an extension in
:const:`_EXT_YAML`.
"""
if not profiles_dir:
profiles_dir = SettingsDict.PATH_PROFILES
paths = glob.glob(os.path.join(
profiles_dir, "{}*".format(filename_prefix)))
return [p for p in paths
if os.path.splitext(p)[1].lower() in SettingsDict._EXT_YAML]
[docs]
def modify_settings(self, mods: Dict[Union[str, Enum], Union[Dict, str]]):
"""Modify dictionary or data class items from another dictionary.
If corresponding values are sub-dictionaries, the existing sub-dict
will be updated rather than replaced with the new sub-dict.
Priority is given to updated existing dictionary keys. If unavailable,
data class attributes are updated.
Args:
mods: Dictionary to update this class' dictionary.
"""
for key in mods.keys():
mod_is_dict = isinstance(mods[key], dict)
try:
# incorporate given settings into self as dict
if isinstance(self[key], dict) and mod_is_dict:
# if both current and new setting values are dicts,
# update rather than replacing the current dict
self[key].update(mods[key])
else:
# replace the value at the setting with the modified val
self[key] = mods[key]
except KeyError:
try:
# incorporate as data class attributes
attr = getattr(self, key)
if isinstance(attr, dict) and mod_is_dict:
attr.update(mods[key])
else:
setattr(self, key, mods[key])
except AttributeError as e:
_logger.debug(e)
_logger.debug("Ignoring preference key: %s", key)
[docs]
def get_profile(
self, profile_name: str
) -> Optional[Dict[Union[str, Enum], Union[Dict, str]]]:
"""Get the dictionary for a given profile.
Profiles may either match an existing profile in :attr:`profiles`
or specify a path to a YAML configuration file. YAML filenames will
first be checked in :const:`PATH_PROFILES`, followed by
``profile_name`` as the full path. If :attr:`_add_mod_directly` is True,
the value at ``profile_name`` will be added or replaced with the found
modifier value.
The profile in :attr:`profiles` whose name matches ``profile_name``
will be applied over the current settings. If both the current and new
values are dictionaries, the current dictionary will be updated
with the new values. Otherwise, the corresponding current value will be
replaced by the new value.
Args:
profile_name: Profile name.
Returns:
The loaded dictionary, or None if not found.
"""
if os.path.splitext(profile_name)[1].lower() in self._EXT_YAML:
# load YAML files from profiles directory
prof_path = os.path.join(self.PATH_PROFILES, profile_name)
if not os.path.exists(prof_path):
# fall back to loading directly from given path
print("{} profile file not found, checking {}"
.format(prof_path, profile_name))
prof_path = profile_name
if not os.path.exists(prof_path):
print(prof_path, "profile file not found, skipped")
return None
self.timestamps[prof_path] = os.path.getmtime(prof_path)
mods = {}
try:
yamls = yaml_io.load_yaml(prof_path, _PROFILE_ENUMS)
for yaml in yamls:
mods.update(yaml)
_logger.info("Loaded profile from '%s':\n%s", prof_path, mods)
except FileNotFoundError:
_logger.warn("Unable to load profile from: %s", prof_path)
else:
if profile_name == self.DEFAULT_NAME:
# update entries from a new instance for default values;
# use class name to access any profile subclasses
mods = self.__class__()
else:
# must match an available modifier name
if profile_name not in self.profiles:
print(profile_name, "profile not found, skipped")
return None
mods = self.profiles[profile_name]
return mods
[docs]
def add_profiles(self, names_str):
"""Add profiles by names and files.
Layers profiles on top of one another so that any settings in the
next profile take precedence over those in the prior profiles.
For example, "lightsheet_5x" will give one profile, while
"lightsheet_5x_contrast" will layer additional settings on top of the
original lightsheet profile.
Args:
names_str (str): The name of the settings profile to apply,
with individual profiles separated by ",". Profiles will
be applied in order of appearance.
"""
profiles = names_str.split(self.delimiter)
for profile in profiles:
# update self with any combo of profiles, where the order of
# profiles determines the precedence of settings
mods = self.get_profile(profile)
if mods:
self[self.NAME_KEY] += self.delimiter + profile
self.modify_settings(mods)
if config.verbose:
_logger.debug("settings for '%s':", self[self.NAME_KEY])
_logger.debug(pprint.pprint(self))
[docs]
def check_file_changed(self):
"""Check whether any profile files have changed since last loaded.
Returns:
bool: True if any file has changed.
"""
for key, val in self.timestamps.items():
if val < os.path.getmtime(key):
return True
return False
[docs]
def refresh_profile(self, check_timestamp=False):
"""Refresh the profile.
Args:
check_timestamp (bool): True to refresh only if a loaded
profile file has changed; defaults to False.
"""
if not check_timestamp or self.check_file_changed():
# applied profiles are stored in the settings name
profile_names = self[self.NAME_KEY]
self.__init__()
self.add_profiles(profile_names)
[docs]
@staticmethod
def is_identical_settings(profs, keys):
"""Check whether the given settings are identical across profiles.
Args:
profs (Sequence[:class:`ROIProfile`]): Sequence of ROI profiles.
keys (Sequence[str]): Sequence of setting keys to check.
Returns:
bool: True if the settings are identical, otherwise False.
"""
prof_first = None
for prof in profs:
if prof_first is None:
# will compare to first profile
prof_first = prof
else:
for key in keys:
if prof_first[key] != prof[key]:
# any non-equal setting means profiles do not have
# identical block settings
print("Block settings are not identical")
return False
print("Block settings are identical")
return True
[docs]
def save_settings(self, path: Union[str, "pathlib.Path"]) -> Dict:
"""Save current settings to YAML file.
Args:
path: Output path.
Returns:
Saved dictionary, including any modifications
"""
# update dict with attributes from data class fields
self.update(dataclasses.asdict(self))
# save dict to YAML
return yaml_io.save_yaml(path, self, convert_enums=True)