Source code for ClearMap.config.config_handler

"""
This module provides a class to load/write configuration files from the ClearMap configuration directory.

The configuration files are used to store the parameters for the ClearMap processing steps.
Supported formats are .cfg (ConfigObj), .yml/.yaml (YAML) and .json (JSON).
Other formats (TOML, INI, XML, etc.)  could be supported in the future through
a simple plugin function to this module.
"""
import inspect
import json
import re
import warnings
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Callable, Optional, Any, Dict, List, Mapping, Sequence, Tuple, Set
from functools import cached_property

import configobj
from packaging.version import Version
from importlib_metadata import version as importlib_version

from ClearMap.IO.FileUtils import atomic_replace
from .utils import _handle_configobj_failed_parse, configobj_to_dict
from ..Utils.path_utils import clean_path, de_duplicate_path
from ..Utils.utilities import title_to_snake

clearmap_version = importlib_version('ClearMap')
VERSION_SUFFIX = f'v{Version(clearmap_version).major}_{Version(clearmap_version).minor}'

# FIXME: implement validation


INSTALL_CFG_DIR = Path(inspect.getfile(inspect.currentframe())).parent.absolute()  # Where this file resides (w cfgs)
CLEARMAP_CFG_DIR = Path('~/.clearmap/').expanduser()


# Optional YAML dependency
try:
    import yaml  # type: ignore
    _HAS_YAML = True
except Exception:
    _HAS_YAML = False


[docs] def flatten_list(groups: list[list[str]]) -> list[str]: """Flatten a list of lists of item groups into a single list.""" flat: list[str] = [] for names in groups: flat.extend(names) return flat
[docs] class Scope(Enum): EXPERIMENT = 'experiment' GROUP = 'group' GLOBAL = 'global'
[docs] @dataclass(frozen=True) class ConfigSpec: names: tuple[str, ...] # ordered alternatives scope: Scope # EXPERIMENT or GLOBAL subdir: str = '' # optional subfolder, e.g. "configs" or "settings"
""" List of alternative names for configuration files. """ PIPELINE_SECTIONS = [ ['sample', 'sample_info', 'sample info'], ['stitching'], ['registration'], ['cell_map', 'cell_counter'], ['tract_map'], ['vasculature', 'tube_map'], ['colocalization'] ] GROUP_SECTIONS = [ ['batch_processing', 'batch', 'batch processing'], ['group_analysis', 'group analysis'], ] GLOBAL_SECTIONS = [ ['machine', 'preferences'], ['display'], ] LEGACY_SECTIONS = [ ['alignment', 'processing'], # legacy names for stitching/registration tab ] _EXCLUDED_SAMPLE_SCAN_DIRS_DEFAULT: Tuple[str, ...] = ("configs_backup", "config_snapshots")
[docs] class ConfigAlternativesRegistry: """ Registry of alternative names for configuration files. """ def __init__(self): self._pipeline_groups: List[List[str]] = [list(g) for g in PIPELINE_SECTIONS] self._group_groups: List[List[str]] = [list(g) for g in GROUP_SECTIONS] self._global_groups: List[List[str]] = [list(g) for g in GLOBAL_SECTIONS] self._legacy_groups: List[List[str]] = [list(g) for g in LEGACY_SECTIONS] self._registry: Dict[str, ConfigSpec] = {} # experiment and group specs self._extend_registry(self._pipeline_groups, scope=Scope.EXPERIMENT) self._extend_registry(self._group_groups, scope=Scope.GROUP) self._extend_registry(self._global_groups, scope=Scope.GLOBAL) # compatibility specs for group in self._legacy_groups: # FIXME: we need a flag to mark these as deprecated for name in group: self._registry.setdefault(name, ConfigSpec(names=tuple(group), scope=Scope.EXPERIMENT)) def _extend_registry(self, groups, scope: Scope): for group in groups: spec = ConfigSpec(names=tuple(group), scope=scope) for name in group: self._registry[name] = spec @staticmethod def _normalise_name(cfg_name) -> str: cfg_name = ConfigHandler.strip_params(cfg_name) cfg_name = ConfigHandler.strip_version_suffix(cfg_name) return cfg_name
[docs] def get_spec(self, cfg_name: str) -> ConfigSpec: # REFACTOR: unused """ Get the ConfigSpec for a given configuration name. Parameters ---------- cfg_name: str The base name of the configuration file (without params and extension). Returns ------- ConfigSpec The configuration specification. Raises ------ KeyError If no specification is found for the given configuration name. """ return self._registry[self._normalise_name(cfg_name)]
@cached_property def alternative_names(self) -> list[list[str]]: """ Get all alternative names registered. Returns ------- list[str] A list of all alternative configuration names. """ return list(self._pipeline_groups + self._group_groups + self._global_groups + self._legacy_groups)
[docs] def get_alternatives(self, cfg_name: str) -> list[str]: """ Get the list of alternative names for a given configuration name. Parameters ---------- cfg_name: str The base name of the configuration file (without params and extension). Returns ------- list[str] A list of alternative names for the configuration file. Raises ------ ValueError If no alternatives are found for the given configuration name. """ cfg_name = self._normalise_name(cfg_name) alternatives = [names for names in self.alternative_names if cfg_name in names] if not alternatives: raise ValueError(f'Could not find any alternative for {cfg_name}') return alternatives[0]
[docs] def is_local_file(self, cfg_name: str) -> bool: """Check if the given config name is a tab file (i.e. has alternatives).""" return self._normalise_name(cfg_name) in flatten_list(self._pipeline_groups + self._group_groups)
[docs] def is_global_cfg(self, cfg_name: str) -> bool: """Check if the given config name is a global config (i.e. machine, display, preferences).""" return self._normalise_name(cfg_name) in flatten_list(self._global_groups)
[docs] def is_legacy_cfg(self, cfg_name: str) -> bool: """Check if the given config name is a legacy config (i.e. alignment, processing).""" return self._normalise_name(cfg_name) in flatten_list(self._legacy_groups)
[docs] def pipeline_to_section_name(self, pipeline_name: str) -> str: """ Convert a pipeline name to a configuration section name. E.g. 'TubeMap' -> 'vasculature' Parameters ---------- pipeline_name: str The name of the pipeline. Returns ------- str The corresponding configuration section name. """ tentative_section_name = title_to_snake(pipeline_name) for group in self.alternative_names: if tentative_section_name in group: return group[0] # return canonical name return tentative_section_name # assume name correct as is if all else fails
@cached_property def canonical_config_names(self): """ Get the canonical configuration names (first in each alternative group). Returns ------- list[str] A list of canonical configuration names. """ return [names[0] for names in (self._pipeline_groups + self._group_groups + self._global_groups)] @cached_property def canonical_global_config_names(self): """ Get the canonical configuration names for global sections. Returns ------- list[str] A list of canonical configuration names for global sections. """ return [names[0] for names in self._global_groups] @cached_property def canonical_pipeline_config_names(self): """ Get the canonical configuration names for pipeline sections. Returns ------- list[str] A list of canonical configuration names for pipeline sections. """ return [names[0] for names in self._pipeline_groups] @cached_property def canonical_group_config_names(self): # FIXME: USE """ Get the canonical configuration names for group sections. Returns ------- list[str] A list of canonical configuration names for group sections. """ return [names[0] for names in self._group_groups]
[docs] def to_canonical(self, cfg_name): return self.get_alternatives(cfg_name)[0]
[docs] @staticmethod def get_channel_sections() -> tuple[str, ...]: # REFACTOR: could use 'channels' in schema root for schema in schemas return ("sample", "stitching", "registration", "cell_map", "colocalization", "vasculature", "tract_map",)
ALTERNATIVES_REG = ConfigAlternativesRegistry()
[docs] @dataclass class ConfigProxy(dict): """ Dict-like wrapper that remembers filename and provides write()/reload(). Used to return from get_*_cfg functions. This is basically just a dict with I/O methods attached. Compatible with ConfigObj interface for yml/json/cfg files. """ filename: str _loader: Callable[[Path], "ConfigProxy"] _dumper: Callable[[Path, dict], None]
[docs] def write(self, outfile: Optional[Any] = None) -> None: """ Backwards-compatible signature. If outfile is given (a file-like), we still write to our filename atomically, ignoring outfile (kept for compatibility). """ path = Path(self.filename) tmp = path.with_suffix(f'{path.suffix}.tmp') self._dumper(tmp, dict(self)) atomic_replace(tmp, path)
[docs] def reload(self) -> "ConfigProxy": return self._loader(Path(self.filename))
# ################# WRITER FUNCTIONS ####################
[docs] def to_configobj(path: Path, data: dict) -> None: """ Write a dict to a .cfg/.ini file using ConfigObj. Parameters ---------- path: Path Path to the output .cfg/.ini file data: dict Data to write """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) cfg = configobj.ConfigObj(encoding='UTF8', indent_type=' ', unrepr=True) cfg.filename = str(path) cfg.clear() for k, v in data.items(): cfg[k] = v cfg.write()
PRIORITY_KEYS = ('clearmap_version', 'clearmap_schema')
[docs] def prioritize_top_keys(obj, first=PRIORITY_KEYS): """Return a shallowly-reordered top-level mapping.""" if not isinstance(obj, dict): return obj out = {} for k in first: if k in obj: out[k] = obj[k] for k, v in obj.items(): if k not in out: out[k] = v return out
[docs] class FlowList(list): """Marker type for inline (flow-style) sequences.""" pass
[docs] def represent_flow_list(dumper, data): return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=True)
class _IndentedSafeDumper(yaml.SafeDumper): """SafeDumper that always indents block-sequence items under their parent key.""" def increase_indent(self, flow=False, indentless=False): return super().increase_indent(flow=flow, indentless=False) # never indentless _IndentedSafeDumper.add_representer(FlowList, represent_flow_list) yaml.add_representer(FlowList, represent_flow_list) yaml.add_representer(FlowList, represent_flow_list, Dumper=yaml.SafeDumper) _SCALARS = (str, int, float, bool, type(None))
[docs] def is_all_scalars(seq): return all(isinstance(x, _SCALARS) for x in seq)
[docs] def mark_inline_sequences(obj, max_items=3): """Inline short sequences of scalars; convert tuples to lists (portable YAML).""" if isinstance(obj, dict): return {k: mark_inline_sequences(v, max_items) for k, v in obj.items()} # Treat both lists and tuples as sequences if isinstance(obj, (list, tuple)): # Recurse into children first items = [mark_inline_sequences(v, max_items) for v in obj] # Decide flow/block if len(items) <= max_items and is_all_scalars(items): return FlowList(items) # -> [a, b, c] return items # -> block style with dashes return obj
[docs] def to_yml(path: Path, data: dict) -> None: """ Write a dict to a .yml/.yaml file using PyYAML. Parameters ---------- path: Path Path to the output .yml/.yaml file data: dict Data to write Raises ------ RuntimeError If PyYAML is not installed. """ if not _HAS_YAML: raise RuntimeError('PyYAML is not installed; cannot write YAML configs.') path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) pretty = mark_inline_sequences(data, max_items=3) pretty = prioritize_top_keys(pretty) with path.open('w', encoding='utf-8') as f: yaml.dump(pretty, f, Dumper=_IndentedSafeDumper, sort_keys=False, allow_unicode=True, default_flow_style=False)
[docs] def to_json(path: Path, data: dict) -> None: """ Write a dict to a .json file using the json module. Parameters ---------- path: Path Path to the output .json file data: dict Data to write """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with path.open('w', encoding='utf-8') as f: json.dump(data, f, indent=2)
# ################# READER FUNCTIONS ####################
[docs] def get_configobj_cfg(cfg_path: Path | str, must_exist: bool = True) -> ConfigProxy | None: """ Load a .cfg/.ini file using ConfigObj and return as ConfigProxy. Parameters ---------- cfg_path: Path | str Path to the .cfg/.ini file must_exist: bool If True, raise an error if the file does not exist. Returns ------- ConfigProxy | None The configuration as a ConfigProxy object, or None if the file does not exist and must_exist is False. Raises ------ ConfigParsingError If the file cannot be parsed. """ cfg_path = str(clean_path(cfg_path)) # str to be future-proof. ConfigObj wants str try: cobj = configobj.ConfigObj(cfg_path, encoding="UTF8", indent_type=' ', unrepr=True, file_error=must_exist) except configobj.ConfigObjError as err: msg = str(err) # Parse error message for # "Parsing failed with several errors. First error at line 19." # to reraise with more context if 'parsing failed' in msg.lower(): _handle_configobj_failed_parse(err, Path(cfg_path)) else: warnings.warn(f'Could not read config file "{cfg_path}", some errors were encountered: "{err}"') return None cfg = ConfigProxy( filename=str(cfg_path), _loader=lambda p: get_configobj_cfg(p, must_exist=True), # type: ignore _dumper=to_configobj ) cfg.update(configobj_to_dict(cobj)) return cfg
[docs] def get_yml_cfg(cfg_path: Path, must_exist: bool = True) -> ConfigProxy | None: """ Load a .yml/.yaml file using PyYAML and return as ConfigProxy. Parameters ---------- cfg_path: Path Path to the .yml/.yaml file must_exist: bool If True, raise an error if the file does not exist. Returns ------- ConfigProxy | None The configuration as a ConfigProxy object, or None if the file does not exist and must_exist is False. """ if not _HAS_YAML: raise RuntimeError('PyYAML is not installed; cannot read YAML configs.') cfg_path = Path(clean_path(cfg_path)) if not cfg_path.exists(): if must_exist: raise FileNotFoundError(f'YAML config file {cfg_path} does not exist.') return None with cfg_path.open('r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} if not isinstance(data, dict): raise ValueError(f'YAML root must be a mapping; got {type(data)} in {cfg_path}') cfg = ConfigProxy( filename=str(cfg_path), _loader=lambda p: get_yml_cfg(p, must_exist=True), # type: ignore _dumper=to_yml) cfg.update(data) return cfg
[docs] def get_json_cfg(cfg_path: Path, must_exist: bool = True) -> ConfigProxy | None: """ Load a .json file using the json module and return as ConfigProxy. Parameters ---------- cfg_path: Path Path to the .json file must_exist: bool If True, raise an error if the file does not exist. Returns ------- ConfigProxy | None The configuration as a ConfigProxy object, or None if the file does not exist and must_exist is False. """ cfg_path = Path(clean_path(cfg_path)) if not cfg_path.exists(): if must_exist: raise FileNotFoundError(f'JSON config file {cfg_path} does not exist.') return None with cfg_path.open('r', encoding='utf-8') as f: data = json.load(f) if cfg_path.exists() else {} if not isinstance(data, dict): raise ValueError(f'JSON root must be a mapping; got {type(data)} in {cfg_path}') cfg = ConfigProxy( filename=str(cfg_path), _loader=lambda p: get_json_cfg(p, must_exist=True), # type: ignore _dumper=to_json ) cfg.update(data) return cfg
# #################################################################
[docs] def patch_cfg(cfg, default_cfg): """ Recursively patch cfg with missing keys from default_cfg. Parameters ---------- cfg: dict-like The configuration to patch. default_cfg: dict-like The default configuration to use for patching. """ for k, v in default_cfg.items(): if k not in cfg.keys(): cfg[k] = v # everything below will match by definition else: if isinstance(v, dict): if not isinstance(cfg[k], dict): raise ValueError(f'Cannot merge dict into non-dict for key {k}.' f'Please first convert your config to a dict with e.g. configobj_to_dict.') patch_cfg(cfg[k], v)
[docs] class ConfigHandler: """ Resolves logical config names to files and loads them via readers; writes using writer_functions (atomic). Class Attributes ---------------- loader_functions: Dict[str, Callable[[Path, bool], Optional[ConfigProxy]]] A dictionary mapping file extensions to their corresponding loader functions. writer_functions: Dict[str, Callable[[Path, dict], None]] A dictionary mapping file extensions to their corresponding writer functions. user_defaults_dir: Path The default directory where configuration files are located. supported_exts: tuple A tuple of supported file extensions for configuration files. Attributes ---------- src_dir: Path The source directory where configuration files are located. """ # Readers by extension loader_functions: Dict[str, Callable[[Path, bool], Optional[ConfigProxy]]] = { ".yml": get_yml_cfg, ".yaml": get_yml_cfg, ".cfg": get_configobj_cfg, ".ini": get_configobj_cfg, ".json": get_json_cfg, } # Writers by extension writer_functions: Dict[str, Callable[[Path, dict], None]] = { ".yml": to_yml, ".yaml": to_yml, ".cfg": to_configobj, ".ini": to_configobj, ".json": to_json, } supported_exts = tuple(loader_functions.keys()) user_defaults_dir = CLEARMAP_CFG_DIR # Where to look for user defaults user_global_dir = CLEARMAP_CFG_DIR / 'config' # Where to look for user global configs def __init__(self, src_dir: Path | str): self._src_dir = None self.roots = { "experiment": Path.cwd(), # overridden by repository via src_dir setter "group": Path.cwd(), # same as experiment for now "global": self.user_defaults_dir, # ~/.clearmap } # Before setting src_dir because setter uses it self.src_dir = src_dir @property def src_dir(self): return self._src_dir @src_dir.setter def src_dir(self, value: str | Path): self._src_dir = Path(value).expanduser().resolve() self.roots['experiment'] = self._src_dir self.roots['group'] = self._src_dir
[docs] @staticmethod def is_global(cfg_name: str) -> bool: return ALTERNATIVES_REG.is_global_cfg(cfg_name)
[docs] @staticmethod def is_local(cfg_name: str) -> bool: return ALTERNATIVES_REG.is_local_file(cfg_name)
[docs] @staticmethod def strip_params(cfg_name: str, params_pattern: str = '_params') -> str: """Strip params from config name, e.g. _v2_params from sample_v2_params""" return re.sub(params_pattern, '', cfg_name)
[docs] @staticmethod def strip_version_suffix(cfg_name: str) -> str: """Strip version suffix from config name, e.g. _v2_3 from sample_v2_3_params""" pattern = r'_v\d+_\d+$' return re.sub(pattern, '', cfg_name)
[docs] @staticmethod def normalise_cfg_name(cfg_name: str) -> str: """Normalise config name by stripping params and version suffix.""" name = str(cfg_name) name = ConfigHandler.strip_params(name) name = ConfigHandler.strip_version_suffix(name) return name
@staticmethod def _layout_subdir_for_version(version: str, defaults: bool) -> Path: v = Version(version) if v >= Version('3.1'): # >= 3.1 has “defaults/vX.Y/” prefix = Path('defaults') if defaults else Path() return prefix / f'v{v.major}.{v.minor}' else: return Path() @staticmethod def _filename_for(cfg_base: str, ext: str, *, version: str, is_exp_local: bool) -> str: v = Version(version) base = cfg_base # already stripped of *_params and version bits before if v >= Version('3.1'): # New layout: no prefix/suffix in filename return f'{base}{ext}' else: # Legacy (<= 3.0) prefix = 'default_' if is_exp_local else "" suffix = '_params' if v == Version('3.0'): # v3.0 specifically had _v3_0 suffix suffix += '_v3_0' return f'{prefix}{base}{suffix}{ext}' # ####### Canonical (where it should be in that version, don't scan) ##########
[docs] def get_local_canonical_path(self, cfg_name: str) -> Path: canonical_name = ALTERNATIVES_REG.to_canonical(cfg_name) canonical_filename = self._filename_for(canonical_name, self.supported_exts[0], version=clearmap_version, is_exp_local=True) return self.src_dir / canonical_filename
[docs] @classmethod def get_global_canonical_path(cls, cfg_name: str) -> Path: canonical_name = ALTERNATIVES_REG.to_canonical(cfg_name) return cls._name_to_shared_path(canonical_name, cls.supported_exts[0], root=cls.user_global_dir, version=clearmap_version, defaults=False)
[docs] @classmethod def get_user_defaults_canonical_path(cls, cfg_name: str) -> Path: """ Canonical path for a user default config for the *current* ClearMap version. - No scanning / fallback across versions - Uses current clearmap_version - Always uses the given ext (default: .yml for 3.1+) """ canonical = ALTERNATIVES_REG.to_canonical(cls.normalise_cfg_name(cfg_name)) return cls._name_to_shared_path(canonical, cls.supported_exts[0], root=cls.user_defaults_dir, version=str(Version(clearmap_version)), defaults=True)
[docs] def get_canonical_path(self, cfg_name: str) -> Path: """ Get the canonical path to the configuration file with the given name. The path is determined based on whether the config is local (tab/experiment) or global (machine/display/preferences). The rest is determined by the current ClearMap version and layout as well as preferred extension. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file. Returns ------- Path The canonical path to the configuration file """ cfg_name = self.normalise_cfg_name(cfg_name) if ALTERNATIVES_REG.is_global_cfg(cfg_name): return self.get_global_canonical_path(cfg_name) elif ALTERNATIVES_REG.is_local_file(cfg_name): return self.get_local_canonical_path(cfg_name) else: raise ValueError(f'Config "{cfg_name}" is neither local nor global (maybe legacy); ' f'cannot get canonical path.')
[docs] @classmethod def resolve_write_path(cls, name: str, *, base_dir: Path) -> Path: """ Resolve the *target* path to write a config by name. - Tab/experiment sections -> experiment base_dir - Global sections (machine/display/preferences) -> user global directory """ loader = cls(base_dir) return loader.get_canonical_path(name)
@classmethod def _name_to_shared_path(cls, cfg_name: str, ext: str, *, root: Path, version: Optional[str] = None, defaults: bool = True) -> Path: """ Helper to construct a *shared* (i.e. non experiment-local) config path from name + ext. The path is either in the ClearMap package directory or in the user's ~/.clearmap directory. This is typically used for both default (package or user) and global (user) configs. The folder structure depends on the version and `defaults` flag: - defaults=True → .../defaults/vX.Y/<name>.<ext> (>= 3.1) - defaults=False → .../vX.Y/<name>.<ext> (>= 3.1) - < 3.1 → root / legacy naming patterns Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file ext: str The file extension root: Path The root directory to look for the default config file. version: Optional[str] The version string in the major.minor format to use for the folder structure. If None, the current ClearMap version is used. """ v = Version(version or clearmap_version) # default to latest layout base = cls.strip_params(cfg_name) is_exp_local = ALTERNATIVES_REG.is_local_file(base) sub_dir = cls._layout_subdir_for_version(str(v), defaults=defaults) sub_dir = de_duplicate_path(root, sub_dir) # avoid duplicate subdir suffix file_name = cls._filename_for(base, ext, version=str(v), is_exp_local=is_exp_local) return Path(root / sub_dir / file_name).expanduser().resolve() # _find paths: ####### Scan FS for existing files ########## def _find_local_path(self, cfg_name: str) -> Path | None: # TODO: check """ Scan the source directory for a *local* configuration file with the given name. The names are resolved via the alternatives registry. Several extensions are tried in order of preference. If None is found, None is returned. Parameters ---------- cfg_name: str The name (with or without params and extension) of the configuration file Returns ------- Path | None The path to the configuration file, or None if not found. """ cfg_name = self.normalise_cfg_name(cfg_name) try: variants = ALTERNATIVES_REG.get_alternatives(cfg_name) except ValueError: variants = [cfg_name] src_dir = self.src_dir # Support for legacy file name patterns, newer to older search_patterns = [ lambda base, ext: [src_dir / f"{base}{ext}"], # >= 3.1 default_base.ext lambda base, ext: sorted(src_dir.glob(f"{base}_v*_*{ext}")), # v3.0. WARNING: needs re if we want to limit to integers lambda base, ext: [src_dir / f"{base}_params{ext}"], # <= 3.0 default_base_params.ext lambda base, ext: sorted(src_dir.glob(f"{base}_params_v*_*{ext}")) # Do we really have this ? ] for alternative_name in variants: for extension in self.supported_exts: for ptrn_func in search_patterns: for cfg_path in ptrn_func(alternative_name, extension): if cfg_path.exists(): return cfg_path else: return None @classmethod def _find_shared_cfg_path(cls, cfg_name: str, *, root: Path, version: Optional[str], defaults: bool) -> Path | None: """ Get the path to a *shared* configuration file with the given name. Several extensions are tried in order of preference. If None is found, None is returned. This is used for both default (package or user) and global (user) configs. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file must_exist: bool Whether the file must exist. If missing and True, a FileNotFoundError is raised root: Path The base directory to look for the default config file. If None, the directory is determined by from_package. Typically, this would be either the ClearMap package directory or the user's ~/.clearmap directory. version: Optional[str] The version string in the major.minor format to use for the folder structure. If None, the current ClearMap version is used. defaults: bool If True, look in the 'defaults' subdirectory (for >=v3.1). Otherwise, look at the root of base_dir. (or what it resolves to) Returns ------- Path The path to the default configuration file Raises ------ FileNotFoundError If the file does not exist and must_exist is True. """ cfg_name = cls.normalise_cfg_name(cfg_name) versions_to_try = [version] if version else (clearmap_version, '3.0', '2.1') # Currently 3 kinds of layout (read up to vX.Y) paths_checked = [] # FIXME: include this in caller errors for base in ALTERNATIVES_REG.get_alternatives(cfg_name): for ext in cls.supported_exts: for ver in versions_to_try: candidate = cls._name_to_shared_path(base, ext, root=root, version=ver, defaults=defaults) paths_checked.append(candidate) if candidate.exists(): return candidate else: return None # ############## Get Paths: scan or canonical ###############
[docs] def get_cfg_path(self, cfg_name: str, must_exist: bool = True) -> Path: """ Get the path to the configuration file with the given name. Several extensions are tried in order of preference. If None is found, the canonical path is returned if must_exist is False or a FileNotFoundError is raised if must_exist is True. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file. If present, they will be stripped. must_exist: bool Whether the file must exist. If missing and True, a FileNotFoundError is raised. Returns ------- Path The path to the configuration file """ cfg_name = self.normalise_cfg_name(cfg_name) if ALTERNATIVES_REG.is_global_cfg(cfg_name): raise ValueError(f'Config "{cfg_name}" is global; use get_global_path() instead of get_cfg_path().') cfg_path = self._find_local_path(cfg_name) if cfg_path is None: if must_exist: raise FileNotFoundError(f'Could not find file {cfg_name} in {self.src_dir} with variants:' f'{ALTERNATIVES_REG.get_alternatives(cfg_name)}') else: return self.get_local_canonical_path(cfg_name) else: return cfg_path
[docs] @classmethod def get_global_path(cls, cfg_name: str, must_exist: bool = True, *, base_dir: Path | None = None, version: Optional[str] = None) -> Path | None: """ Get the path to the global configuration file with the given name. Several extensions are tried in order of preference. If None is found, the first possible option is returned if must_exist is False or a FileNotFoundError is raised if must_exist is True. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file must_exist: bool Whether the file must exist. If missing and True, a FileNotFoundError is raised base_dir: Optional[Path] The base directory to look for the global config file. If None, the user's ~/.clearmap directory is used. version: Optional[str] The version string in the major.minor format to use for the folder structure. Returns ------- Path The path to the global configuration file """ root = Path(base_dir) if base_dir is not None else cls.user_global_dir version = version or clearmap_version if not must_exist: return cls._find_shared_cfg_path(cfg_name, root=root, version=version, defaults=False) else: global_path = cls._find_shared_cfg_path(cfg_name, root=root, version=version, defaults=False) if global_path is not None: return global_path else: if base_dir is not None: raise FileNotFoundError(f'Could not find global config {cfg_name} in {base_dir}') if not must_exist: loader = cls(root) return loader.get_global_canonical_path(cfg_name) else: legacy_root = cls.user_defaults_dir # Old user_global_dir was same as default global_path = cls._find_shared_cfg_path(cfg_name, root=legacy_root, version=version, defaults=False) if global_path is None and must_exist: raise FileNotFoundError(f'Could not find global config "{cfg_name}" in "{root}" or "{legacy_root}"') else: return global_path
[docs] @classmethod def get_default_path(cls, cfg_name: str, must_exist: bool = True, *, base_dir: Path | None = None, version: Optional[str] = None, from_package: bool = False) -> Path | None: """ Get the path to the default configuration file with the given name. Several extensions are tried in order of preference. If None is found, the first possible option is returned if must_exist is False or a FileNotFoundError is raised if must_exist is True. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file must_exist: bool Whether the file must exist. If missing and True, a FileNotFoundError is raised base_dir: Optional[Path] The base directory to look for the default config file. If specified, this overrides the from_package parameter. version: Optional[str] The version string in the major.minor format to use for the folder structure. If None, the current ClearMap version is used. from_package: bool If True, look for the default config in the ClearMap package directory. Otherwise, look in the user's ~/.clearmap directory. .. warning:: This only affects the default path if no base_dir is specified. Returns ------- Path The path to the default configuration file """ version = version or clearmap_version if (base_dir is None and not from_package and ALTERNATIVES_REG.is_global_cfg(cfg_name) and Version(version) >= Version('3.1') ): raise ValueError(f'Config "{cfg_name}" is global; use get_global_path() instead of get_default_path().') root = Path(base_dir) if base_dir is not None else ( INSTALL_CFG_DIR if from_package else cls.user_defaults_dir ) default_path = cls._find_shared_cfg_path(cfg_name, root=root, version=version, defaults=True) if default_path is not None: return default_path else: if must_exist: raise FileNotFoundError(f'Could not find default config "{cfg_name}" in "{root}"') else: canonical = ALTERNATIVES_REG.to_canonical(cfg_name) return cls._name_to_shared_path(canonical, cls.supported_exts[0], root=root, version=version, defaults=True)
# ############# The configs themselves ###############
[docs] def get_cfg(self, cfg_name: str, must_exist: bool = True) -> ConfigProxy | None: """ Get the configuration file with the given name. Several extensions are tried in order of preference. If None is found, None is returned if must_exist is False or a FileNotFoundError is raised if must_exist is True. Parameters ---------- cfg_name: str The name (without params and extension) of the configuration file must_exist: bool Whether the file must exist. If missing and True, a FileNotFoundError is raised Returns ------- ConfigProxy | None The configuration file as a ConfigProxy object or None if not found and must_exist is False """ if '/' in str(cfg_name): # Already a path cfg_path = Path(cfg_name).expanduser().resolve() else: if ALTERNATIVES_REG.is_local_file(cfg_name): cfg_path = self.get_cfg_path(cfg_name, must_exist=must_exist) elif ALTERNATIVES_REG.is_global_cfg(cfg_name): cfg_path = self.get_global_path(cfg_name, must_exist=must_exist) else: cfg_path = self.get_default_path(cfg_name, must_exist=must_exist) if not cfg_path.exists(): if must_exist: raise FileNotFoundError(f'Could not find file {cfg_name} in {self.src_dir} @ {cfg_path}') else: return None return self.get_cfg_from_path(cfg_path)
[docs] @classmethod def get_default_cfg(cls, cfg_name): try: return cls.get_cfg_from_path(cls.get_default_path(cfg_name, from_package=False)) except FileNotFoundError: return cls.get_cfg_from_path(cls.get_default_path(cfg_name, from_package=True))
[docs] @classmethod def get_cfg_from_path(cls, cfg_path: str | Path) -> ConfigProxy: """ Load a configuration file from the given path using the appropriate loader function. The function is determined by the file extension. .. warning:: The file must exist. Parameters ---------- cfg_path: str | Path The path to the configuration file. Must exist. Returns ------- ConfigProxy The configuration file as a ConfigProxy object. """ cfg_path = Path(cfg_path) if not cfg_path.exists(): raise FileNotFoundError(f'Config file {cfg_path} does not exist.') ext = cfg_path.suffix.lower() loader = cls.loader_functions[ext] cfg = loader(cfg_path, must_exist=True) if cfg is None: raise RuntimeError(f'Could not load config from {cfg_path} using loader for {ext}') return cfg
[docs] @classmethod def get_new_cfg_writer(cls, cfg_path: str | Path, *, must_not_exist: bool = True) -> ConfigProxy: """ Create a new, empty ConfigProxy for cfg_path, without reading from disk. Parameters ---------- cfg_path : str | Path Target config path (may or may not exist). must_not_exist : bool If True, raise if the file already exists. Returns ------- ConfigProxy An empty ConfigProxy bound to cfg_path with the proper writer. """ path = Path(cfg_path).expanduser().resolve() if must_not_exist and path.exists(): raise FileExistsError(f'Config file {path} already exists.') ext = path.suffix.lower() dumper = cls.writer_functions.get(ext) if dumper is None: raise ValueError(f'No writer for extension {ext}') return ConfigProxy( filename=str(path), _loader=lambda p: cls.get_cfg_from_path(p), # standard “read me back” loader _dumper=dumper, )
[docs] @classmethod def get_patched_cfg_from_path(cls, cfg_path: str | Path) -> ConfigProxy: """ Load a configuration file from the given path and patch it with the default configuration. The default configuration is loaded from the ClearMap package directory. The function is determined by the file extension. The file must exist. Parameters ---------- cfg_path: str | Path The path to the configuration file. Must exist. Returns ------- ConfigProxy The patched configuration file as a ConfigProxy object. """ cfg_path = Path(cfg_path) cfg = cls.get_cfg_from_path(cfg_path) config_name = cls.strip_version_suffix(cfg_path.stem) default_cfg = cls.get_cfg_from_path(cls.get_default_path(config_name)) patch_cfg(cfg, default_cfg) cfg.write() return cfg
[docs] @classmethod def dump(cls, path: Path, data: dict) -> None: """ Write the given data to the specified path using the appropriate writer function. The function is determined by the file extension. The write is atomic (writes to a temp file then renames). Parameters ---------- path: Path The path to the output configuration file data: dict The data to write Raises ------ ValueError If no writer function is found for the given file extension. """ ext = path.suffix.lower() writer_fn = cls.writer_functions.get(ext) if not writer_fn: raise ValueError(f'No writer for extension {ext}') tmp = path.with_suffix(f'{path.suffix}.tmp') if isinstance(data, Mapping): # ConfigProxy payload = dict(data) else: payload = data writer_fn(tmp, payload) atomic_replace(tmp, path)
# FIXME: DEPRECATED
[docs] def get_configs(cfg_path: str | Path, processing_params_path: str | Path, machine_cfg_path: str | Path | None = None): """ Get the machine, sample and processing configurations from the given paths. If machine_cfg_path is None, the default machine config path is used. Parameters ---------- cfg_path: str | Path Path to the sample configuration file processing_params_path: str | Path Path to the processing parameters configuration file machine_cfg_path: str | Path | None Path to the machine configuration file. If None, the default machine config path is used. Returns ------- Tuple[ConfigProxy, ConfigProxy, ConfigProxy] A tuple containing the machine, sample and processing configurations as ConfigProxy objects. """ if machine_cfg_path is None: machine_cfg_path = ConfigHandler.get_global_path('machine') sample_config = ConfigHandler.get_patched_cfg_from_path(cfg_path) processing_config = ConfigHandler.get_patched_cfg_from_path(processing_params_path) machine_config = ConfigHandler.get_patched_cfg_from_path(machine_cfg_path) return machine_config, sample_config, processing_config
# REFACTOR: why is this not a class method of ConfigHandler?
[docs] def get_cfg_reader_function(cfg_path: Path | str) -> Callable[[Path, bool], Optional[ConfigProxy]]: """ Get the appropriate configuration reader function for the given file path. The function is determined by the file extension. Parameters ---------- cfg_path: Path | str The path to the configuration file. Returns ------- Callable[[Path, bool], Optional[ConfigProxy]] The configuration reader function. """ cfg_path = Path(cfg_path) ext = cfg_path.suffix.lower() read_cfg = ConfigHandler.loader_functions[ext] return read_cfg
[docs] def scan_folder_for_experiments(folder: Path | str, *, exclude_dir_names: Sequence[str] = _EXCLUDED_SAMPLE_SCAN_DIRS_DEFAULT) -> Set[Path]: """ Discover experiment folders under `folder` by scanning recursively for sample config files. A folder is considered an experiment root if it contains a config file whose base name matches one of the 'sample' alternatives and whose extension is supported by ConfigHandler. Excludes any match located under directories listed in `exclude_dir_names`. """ folder = Path(folder).expanduser().resolve() if not folder.is_dir(): return set() exclude_set = set(exclude_dir_names) supported_exts = set(ConfigHandler.supported_exts) sample_alts = ALTERNATIVES_REG.get_alternatives('sample') sample_bases = {ConfigHandler.normalise_cfg_name(title_to_snake(x)) for x in sample_alts} sample_bases.add('sample') def is_excluded(p_: Path) -> bool: return any(part in exclude_set for part in p_.parts) def is_sample_cfg(p_: Path) -> bool: if p_.suffix.lower() not in supported_exts: return False stem_norm = ConfigHandler.normalise_cfg_name(title_to_snake(p_.stem)) if stem_norm.startswith('default_'): return False return stem_norm in sample_bases roots: Set[Path] = set() for p in folder.rglob('*'): if p.is_file() and not is_excluded(p) and is_sample_cfg(p): roots.add(p.parent.resolve()) return roots