"""Configuration YAML pour gitea-dashboard.""" from __future__ import annotations import os import re from pathlib import Path from typing import Any import yaml _DEFAULT_CONFIG_PATHS = [ Path(".gitea-dashboard.yml"), Path.home() / ".config" / "gitea-dashboard" / "config.yml", ] _ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}") def resolve_env_vars(value: str) -> str: """Resout les references ${VAR} dans une valeur string. Remplace ${VAR} par os.environ[VAR]. Si VAR n'est pas defini, laisse la reference telle quelle. Ne resout pas les references imbriquees. """ def _replace(match: re.Match) -> str: var_name = match.group(1) return os.environ.get(var_name, match.group(0)) return _ENV_VAR_RE.sub(_replace, value) def _resolve_values(data: Any) -> Any: """Resout recursivement les ${VAR} dans les valeurs string et listes.""" if isinstance(data, str): return resolve_env_vars(data) if isinstance(data, list): return [_resolve_values(item) for item in data] if isinstance(data, dict): return {k: _resolve_values(v) for k, v in data.items()} return data def load_config(config_path: str | None = None) -> dict[str, Any]: """Charge la configuration depuis un fichier YAML. Ordre de recherche si config_path est None : 1. .gitea-dashboard.yml (repertoire courant) 2. ~/.config/gitea-dashboard/config.yml Retourne un dict vide si aucun fichier trouve. Leve FileNotFoundError si config_path est fourni mais n'existe pas. Leve ValueError si le YAML est syntaxiquement invalide. """ if config_path is not None: path = Path(config_path) if not path.exists(): msg = f"Config file not found: {config_path}" raise FileNotFoundError(msg) return _load_yaml_file(path) for path in _DEFAULT_CONFIG_PATHS: if path.exists(): return _load_yaml_file(path) return {} def _load_yaml_file(path: Path) -> dict[str, Any]: """Charge et parse un fichier YAML avec resolution des variables.""" try: raw = yaml.safe_load(path.read_text(encoding="utf-8")) except yaml.YAMLError as exc: msg = f"Invalid YAML in {path}: {exc}" raise ValueError(msg) from exc if raw is None: return {} if not isinstance(raw, dict): msg = f"Invalid config format in {path}: expected a mapping, got {type(raw).__name__}" raise ValueError(msg) return _resolve_values(raw) def merge_config( cli_args: dict[str, Any], env_vars: dict[str, Any], file_config: dict[str, Any], defaults: dict[str, Any], ) -> dict[str, Any]: """Fusionne les sources de configuration par priorite. Priorite : cli_args > env_vars > file_config > defaults. Une valeur None dans une source de priorite superieure ne masque pas la valeur d'une source de priorite inferieure. """ all_keys = set() for source in (cli_args, env_vars, file_config, defaults): all_keys.update(source.keys()) result: dict[str, Any] = {} for key in all_keys: for source in (cli_args, env_vars, file_config, defaults): value = source.get(key) if value is not None: result[key] = value break return result