163 lines
4.3 KiB
Python
163 lines
4.3 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 SafetyConfig:
|
|
allow_live_game_modification: bool
|
|
allow_prefix_modification: bool
|
|
allow_binary_patch: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AppConfig:
|
|
paths: PathsConfig
|
|
proton7: Proton7Config
|
|
game: GameConfig
|
|
safety: SafetyConfig
|
|
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")
|
|
safety = _section(data, "safety")
|
|
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,
|
|
),
|
|
safety=SafetyConfig(
|
|
allow_live_game_modification=_required_bool(
|
|
safety, "allow_live_game_modification"
|
|
),
|
|
allow_prefix_modification=_required_bool(
|
|
safety, "allow_prefix_modification"
|
|
),
|
|
allow_binary_patch=_required_bool(safety, "allow_binary_patch"),
|
|
),
|
|
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 _required_bool(section: Mapping[str, Any], key: str) -> bool:
|
|
value = section.get(key)
|
|
if not isinstance(value, bool):
|
|
raise ConfigError(f"{key} must be true or false")
|
|
return 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
|