Source code for ClearMap.config.defaults_provider

import warnings
from copy import deepcopy
from pathlib import Path
from typing import Dict, Any, Optional, Iterable

from .config_handler import ConfigHandler, INSTALL_CFG_DIR, ALTERNATIVES_REG
from .validators.json_schema_utils import build_schema_registry_from_dir, load_yaml_schema, compile_validator


JsonDict = Dict[str, Any]

VERSION = 'v3.1'  # REFACTOR: use CLEARMAP_VERSION instead or straight ConfigHAndler for default dirs
SCHEMAS_DIR = INSTALL_CFG_DIR / 'schemas' / VERSION

DEFAULTS_PROVIDER = None

[docs] def get_defaults_provider(): global DEFAULTS_PROVIDER if DEFAULTS_PROVIDER is not None: return DEFAULTS_PROVIDER else: DEFAULTS_PROVIDER = DefaultsProvider(schemas_dir=SCHEMAS_DIR) return DEFAULTS_PROVIDER
# WARNING: for tests only
[docs] def set_defaults_provider(provider): global DEFAULTS_PROVIDER old = DEFAULTS_PROVIDER DEFAULTS_PROVIDER = provider return old
def _relax_schema_for_defaults(schema: dict) -> dict: """ Produce a permissive variant of a JSON Schema for validating *defaults files*: - remove all 'required' constraints recursively - keep types/shapes/ranges (so user-provided values are type-checked) """ from copy import deepcopy as _dc s = _dc(schema) def walk(node: Any): if isinstance(node, dict): node.pop('required', None) for v in node.values(): walk(v) elif isinstance(node, list): for v in node: walk(v) walk(s) return s
[docs] class DefaultsProvider: """ Per-section defaults with precedence: 1) user YAML: ~/.clearmap/defaults/<section>.yml (validated statically) 2) built-in YAML: <repo>/config/defaults/<section>.yml 3) code fallback: provided dicts in code_defaults """ def __init__(self,*, schemas_dir: Optional[Path] = None, sections: Optional[Iterable[str]] = None, tabs_only: bool = False, relax_required_for_defaults: bool = True, code_defaults: Optional[Dict[str, JsonDict]] = None, builtin_dir: Optional[Path] = None, user_dir: Optional[Path] = None # For tests ) -> None: """ Parameters ---------- schemas_dir: Path | None Directory with JSON schemas for static validation If None, no static validation is performed. sections: Iterable[str] | None Sections to load schemas for. If None, all known sections are used. tabs_only: bool If True, only load schemas for sections that correspond to tabs (local files). relax_required_for_defaults: bool If True, 'required' constraints are removed from schemas for static validation code_defaults: Dict[str, JsonDict] | None Fallback defaults per section if no YAML file is found. Useful for tests. builtin_dir: Path | None For tests: override built-in defaults dir user_dir: Path | None For tests: override user defaults dir """ self.schemas_dir = Path(schemas_dir) if schemas_dir else None self.code_defaults = code_defaults or {} all_sections = list(sections) if sections is not None else ALTERNATIVES_REG.canonical_config_names if tabs_only: all_sections = [s for s in all_sections if ALTERNATIVES_REG.is_local_file(s)] self._validators: Dict[str, Any] = {} if self.schemas_dir: self._registry = build_schema_registry_from_dir(self.schemas_dir) for name in all_sections: schema_path = self.schemas_dir / f'{name}.schema.yaml' if schema_path.exists(): schema = load_yaml_schema(schema_path) schema_used = _relax_schema_for_defaults(schema) if relax_required_for_defaults else schema self._validators[name] = compile_validator(schema_used, registry=self._registry) # For tests self.__builtin_dir = Path(builtin_dir).expanduser().resolve() if builtin_dir else None self.__user_dir = Path(user_dir).expanduser().resolve() if user_dir else None if self.__user_dir: self.__user_dir.mkdir(parents=True, exist_ok=True) def _validate_static(self, section: str, data: JsonDict) -> bool: v = self._validators.get(section) if not v: return True try: for _ in v.iter_errors(data): return False return True except Exception as err: # WARNING: broad warnings.warn(f'Static validation error for defaults section "{section}": {err}') return False def __get_cfg(self, section: str, from_package: bool) -> Optional[dict]: base_dir = self.__builtin_dir if from_package else self.__user_dir if ConfigHandler.is_global(section): cfg_path = ConfigHandler.get_global_path(section, must_exist=False, base_dir=base_dir) # For tests if not None else: cfg_path = ConfigHandler.get_default_path(section, must_exist=False, from_package=from_package, base_dir=base_dir) # For tests if not None if not cfg_path.exists(): return None cfg = ConfigHandler.get_cfg_from_path(cfg_path) return dict(cfg) if cfg is not None else None
[docs] def get(self, section: str) -> JsonDict: """Deep-copied defaults for a section, honoring precedence & static validation.""" # 1) user user_cfg = self.__get_cfg(section, from_package=False) if user_cfg is not None and self._validate_static(section, user_cfg): if not user_cfg: raise ValueError(f'Invalid user config for section "{section}"') return deepcopy(user_cfg) # 2) built-in default_cfg = self.get_default_config(section) if not default_cfg: raise ValueError(f'Invalid default config for section "{section}"') return deepcopy(default_cfg)
[docs] def ensure_user_file(self, section: str, out_path: Optional[Path] = None) -> Path: # FIXME: unused """ Ensure ~/.clearmap/defaults/<section>.yml exists by copying built-in (or code fallback). """ if not self.__user_dir: raise RuntimeError("No user_dir configured") dst = ConfigHandler.get_default_path(section, must_exist=False, from_package=False) if dst.exists(): return dst else: dst.parent.mkdir(parents=True, exist_ok=True) src_data = self.get_default_config(section) ConfigHandler.dump(dst, src_data) # atomic write (can convert if format differs) return dst
[docs] def get_default_config(self, section: str) -> dict[str, Any]: default_cfg = self.__get_cfg(section, from_package=True) if default_cfg is not None: return default_cfg else: return dict(self.code_defaults.get(section, {}))