Init
This commit is contained in:
162
ptdiag/config.py
Normal file
162
ptdiag/config.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user