feat(config): add YAML config and graceful pagination timeout
fixes #17, fixes #18 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
src/gitea_dashboard/config.py
Normal file
112
src/gitea_dashboard/config.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user