import os from pathlib import Path from typing import Any, Dict import yaml from .models import ExtractRule, ServiceConfig def load_yaml(path: str) -> Dict[str, Any]: with open(path, "r", encoding="utf-8") as handle: return yaml.safe_load(handle) or {} def load_dotenv(path: str = ".env") -> None: if not os.path.exists(path): return try: with open(path, "r", encoding="utf-8") as handle: for raw_line in handle: line = raw_line.strip() if not line or line.startswith("#"): continue if "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"').strip("'") if key: os.environ.setdefault(key, value) except OSError: return def parse_extract_rule(raw: Any) -> "ExtractRule | None": if not raw: return None rule_type = raw.get("type") value = raw.get("value") if not rule_type or not value: return None allowed = {"jsonpath", "jsonpath_join", "regex", "text", "header"} if rule_type not in allowed: raise ValueError(f"Unsupported extract rule type: {rule_type}") return ExtractRule(type=rule_type, value=value) def load_services(config: Dict[str, Any]) -> Dict[str, ServiceConfig]: services = config.get("services") if not isinstance(services, list): raise ValueError("Config must include a 'services' list") loaded: Dict[str, ServiceConfig] = {} for entry in services: if not isinstance(entry, dict): raise ValueError("Each service entry must be a mapping") name = entry.get("name") base_url = entry.get("base_url") if not name or not base_url: raise ValueError("Each service must include name and base_url") if not isinstance(name, str) or not isinstance(base_url, str): raise ValueError("Service name and base_url must be strings") current_headers = entry.get("current_version_headers") upstream_headers = entry.get("upstream_latest_headers") if current_headers is not None and not isinstance(current_headers, dict): raise ValueError("current_version_headers must be a mapping") if upstream_headers is not None and not isinstance(upstream_headers, dict): raise ValueError("upstream_latest_headers must be a mapping") current_url = entry.get("current_version_url") upstream_url = entry.get("upstream_latest_version_url") current_extract = parse_extract_rule(entry.get("current_version_extract")) upstream_extract = parse_extract_rule(entry.get("upstream_latest_extract")) if current_url and not current_extract: raise ValueError(f"Service {name} must define current_version_extract") if upstream_url and not upstream_extract: raise ValueError(f"Service {name} must define upstream_latest_extract") login = entry.get("login") if login is not None and not isinstance(login, dict): raise ValueError("login must be a mapping") login_socket = entry.get("login_socket") if login_socket is not None and not isinstance(login_socket, dict): raise ValueError("login_socket must be a mapping") loaded[name] = ServiceConfig( name=name, base_url=base_url, current_version_url=current_url, current_version_extract=current_extract, current_version_headers=current_headers, upstream_latest_version_url=upstream_url, upstream_latest_extract=upstream_extract, upstream_latest_headers=upstream_headers, notes=entry.get("notes"), login=login, login_socket=login_socket, ) return loaded def load_services_dir(path: str) -> Dict[str, ServiceConfig]: services_path = Path(path) if not services_path.is_dir(): raise ValueError(f"Services directory not found: {path}") loaded: Dict[str, ServiceConfig] = {} for config_file in sorted(services_path.glob("*/config.yaml")): service_dir = config_file.parent try: config = load_yaml(str(config_file)) except Exception as exc: raise ValueError(f"Failed to load {config_file}: {exc}") from exc for name, svc in load_services({"services": [config]}).items(): svc.service_dir = service_dir loaded[name] = svc return loaded