from __future__ import annotations import json import os from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping, Optional, Union ConfigPath = Union[str, Path] def _default_credentials_path() -> Path: """Return the default path for the credential file.""" xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) return (xdg / "brecal" / "credentials.json").expanduser() @dataclass(frozen=True) class Credentials: """Holds username/password pairs for the BreCal API.""" username: str password: str @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> "Credentials": """Create credentials from a mapping (dict, TOML config, etc.).""" username = _coalesce_key( data, ("username", "user_name", "user"), required="username" ) password = _coalesce_key( data, ("password", "pass", "secret"), required="password" ) if not isinstance(username, str) or not username.strip(): raise ValueError("BreCal credentials require a non-empty username.") if not isinstance(password, str) or not password: raise ValueError("BreCal credentials require a non-empty password.") return cls(username=username.strip(), password=password) @classmethod def load(cls, path: Optional[ConfigPath] = None) -> "Credentials": """Load credentials from a JSON file.""" file_path = Path(path) if path else _default_credentials_path() text = file_path.read_text(encoding="utf-8") data = json.loads(text) return cls.from_mapping(data) @classmethod def from_env( cls, username_var: str = "BRECAL_USERNAME", password_var: str = "BRECAL_PASSWORD" ) -> "Credentials": """Load credentials from environment variables.""" username = os.getenv(username_var) password = os.getenv(password_var) if not username or not password: raise EnvironmentError( f"Missing credentials in env vars {username_var}/{password_var}" ) return cls(username=username, password=password) def _coalesce_key( data: Mapping[str, Any], keys: tuple[str, ...], *, required: str ) -> Any: for key in keys: if key in data: return data[key] raise KeyError(f"Missing '{required}' in credentials mapping.")