Files
plugin-helper/src/plugin_helper/config.py
T
2026-07-09 20:04:10 -07:00

229 lines
7.0 KiB
Python

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)
explicit_runtime_paths = bool(
instances_root_value
or state_dir_value
or _env_instances_roots(repo)
or _env_state_root(repo)
)
profile = (
_select_profile(local_config, profile_id)
if profile_id or not explicit_runtime_paths
else None
)
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,
)