git_brcal/src/brecal_api_client/credentials.py

69 lines
2.4 KiB
Python

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.")