from __future__ import annotations import os import sys import tomllib from dataclasses import dataclass from pathlib import Path from typing import Any LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances" DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT LOCAL_CONFIG_NAME = "plugin-helper.local.toml" WINDOWS_CONFIG_NAME = "plugin-helper.windows.toml" @dataclass(frozen=True) class Profile: id: str label: str | None instances_roots: list[Path] state_root: Path @dataclass(frozen=True) class LocalConfig: instances_roots: list[Path] | None state_root: Path | None profiles: tuple[Profile, ...] @dataclass(frozen=True) class RuntimeConfig: instances_roots: list[Path] state_root: Path config_path: Path config_loaded: bool profile_id: str | None = None profile_label: str | None = None def is_windows() -> bool: return sys.platform == "win32" def repo_root() -> Path: return Path(__file__).resolve().parents[2] def instances_root(value: str | None = None) -> Path: return instances_roots(value)[0] def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]: raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") if raw: return _resolve_path_list(raw, base or repo_root()) return _default_instances_roots() def state_root(value: str | None = None) -> Path: if value: return _resolve_path(value, repo_root()) env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR") if env_state: return _resolve_path(env_state, repo_root()) return _default_state_root() def default_config_path(root: Path | None = None) -> Path: repo = root or repo_root() if is_windows(): windows_config = repo / WINDOWS_CONFIG_NAME if windows_config.is_file(): return windows_config return repo / LOCAL_CONFIG_NAME def _resolve_path(value: str | Path, base: Path) -> Path: path = Path(value).expanduser() if path.is_absolute(): return path return (base / path).resolve() def _resolve_path_list(value: str, base: Path) -> list[Path]: return [_resolve_path(item, base) for item in value.split(os.pathsep) if item] def _parse_profiles(data: dict[str, Any], base: Path) -> tuple[Profile, ...]: profiles: list[Profile] = [] for entry in data.get("profiles", []): if not isinstance(entry, dict): continue profile_id = entry.get("id") instances_value = entry.get("instances_root") state_value = entry.get("state_dir") if not profile_id or not instances_value or not state_value: raise ValueError(f"profile {profile_id!r} needs id, instances_root, and state_dir") profiles.append( Profile( id=str(profile_id), label=entry.get("label"), instances_roots=_resolve_path_list(str(instances_value), base), state_root=_resolve_path(str(state_value), base), ) ) return tuple(profiles) def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]: repo = root or repo_root() path = _resolve_path(config_path, repo) if config_path else default_config_path(repo) if not path.exists(): if config_path: raise FileNotFoundError(f"plugin-helper config not found: {path}") return LocalConfig(instances_roots=None, state_root=None, profiles=()), path, False with path.open("rb") as handle: data: dict[str, Any] = tomllib.load(handle) base = path.parent instances_value = data.get("instances_root") state_value = data.get("state_dir") profiles = _parse_profiles(data, base) return ( LocalConfig( instances_roots=_resolve_path_list(instances_value, base) if instances_value else None, state_root=_resolve_path(state_value, base) if state_value else None, profiles=profiles, ), path, True, ) def _env_instances_roots(root: Path) -> list[Path] | None: value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") return _resolve_path_list(value, root) if value else None def _env_state_root(root: Path) -> Path | None: value = os.environ.get("PLUGIN_HELPER_STATE_DIR") return _resolve_path(value, root) if value else None def _default_state_root() -> Path: if is_windows(): localappdata = os.environ.get("LOCALAPPDATA") if localappdata: return Path(localappdata) / "plugin-helper" return Path.home() / "AppData" / "Local" / "plugin-helper" xdg_state = os.environ.get("XDG_STATE_HOME") base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" return base / "plugin-helper" def _default_instances_roots() -> list[Path]: if is_windows(): return [Path.home() / "BSManager" / "BSInstances"] return [DEFAULT_INSTANCES_ROOT] def _select_profile(local_config: LocalConfig, profile_id: str | None) -> Profile | None: if profile_id: for profile in local_config.profiles: if profile.id == profile_id: return profile available = ", ".join(profile.id for profile in local_config.profiles) or "(none)" raise ValueError(f"unknown profile {profile_id!r}; available profiles: {available}") if ( local_config.profiles and local_config.instances_roots is None and local_config.state_root is None and len(local_config.profiles) == 1 ): return local_config.profiles[0] return None def resolve_runtime_config( *, instances_root_value: str | None = None, state_dir_value: str | None = None, config_path_value: str | None = None, profile_id: str | None = None, root: Path | None = None, ) -> RuntimeConfig: repo = root or repo_root() local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo) profile = _select_profile(local_config, profile_id) resolved_instances = ( _resolve_path_list(instances_root_value, repo) if instances_root_value else _env_instances_roots(repo) or (profile.instances_roots if profile else None) or local_config.instances_roots or _default_instances_roots() ) resolved_state = ( _resolve_path(state_dir_value, repo) if state_dir_value else _env_state_root(repo) or (profile.state_root if profile else None) or local_config.state_root or _default_state_root() ) return RuntimeConfig( instances_roots=resolved_instances, state_root=resolved_state, config_path=loaded_path, config_loaded=loaded, profile_id=profile.id if profile else None, profile_label=profile.label if profile else None, )