Files
pristontale-eu-linux/ptdiag/config.py
2026-09-03 21:52:06 +02:00

138 lines
3.6 KiB
Python

"""Typed TOML configuration loading."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping
import re
import tomllib
DEFAULT_CONFIG_PATH = Path("config/local.toml")
class ConfigError(ValueError):
"""Raised when local configuration is missing or invalid."""
@dataclass(frozen=True)
class PathsConfig:
app_root: Path
live_prefix: Path
live_game: Path
logs: Path
native_logs: Path
@dataclass(frozen=True)
class Proton7Config:
proton: Path
steam_root: Path
compat: Path
prefix: Path
@dataclass(frozen=True)
class GameConfig:
exe: str
args: tuple[str, ...]
virtual_desktop: str
@dataclass(frozen=True)
class AppConfig:
paths: PathsConfig
proton7: Proton7Config
game: GameConfig
source: Path
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
return _paths_to_strings(result)
def project_root() -> Path:
return Path(__file__).resolve().parent.parent
def load_config(path: str | Path | None = None) -> AppConfig:
config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH
config_path = config_path.expanduser().absolute()
try:
with config_path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise ConfigError(
f"Configuration not found: {config_path}. "
"Copy config/local.example.toml to config/local.toml."
) from exc
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
paths = _section(data, "paths")
proton7 = _section(data, "proton7")
game = _section(data, "game")
virtual_desktop = _required_string(game, "virtual_desktop")
if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None:
raise ConfigError("game.virtual_desktop must look like 1600x900")
return AppConfig(
paths=PathsConfig(
app_root=_required_path(paths, "app_root"),
live_prefix=_required_path(paths, "live_prefix"),
live_game=_required_path(paths, "live_game"),
logs=_required_path(paths, "logs"),
native_logs=_required_path(paths, "native_logs"),
),
proton7=Proton7Config(
proton=_required_path(proton7, "proton"),
steam_root=_required_path(proton7, "steam_root"),
compat=_required_path(proton7, "compat"),
prefix=_required_path(proton7, "prefix"),
),
game=GameConfig(
exe=_required_string(game, "exe"),
args=_required_string_tuple(game, "args"),
virtual_desktop=virtual_desktop,
),
source=config_path,
)
def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
value = data.get(name)
if not isinstance(value, Mapping):
raise ConfigError(f"Missing or invalid [{name}] section")
return value
def _required_string(section: Mapping[str, Any], key: str) -> str:
value = section.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{key} must be a non-empty string")
return value
def _required_path(section: Mapping[str, Any], key: str) -> Path:
return Path(_required_string(section, key)).expanduser().absolute()
def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, ...]:
value = section.get(key)
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise ConfigError(f"{key} must be a non-empty array of strings")
return tuple(value)
def _paths_to_strings(value: Any) -> Any:
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {key: _paths_to_strings(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_paths_to_strings(item) for item in value]
return value