Skip to content

Commit

Permalink
Merge pull request #205 from xen0n/externally-managed-ruyi
Browse files Browse the repository at this point in the history
Better support externally-managed installations
  • Loading branch information
xen0n authored Sep 29, 2024
2 parents 0253890 + 7728d4d commit 09f1e45
Show file tree
Hide file tree
Showing 10 changed files with 84 additions and 36 deletions.
4 changes: 2 additions & 2 deletions ruyi/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def is_called_as_ruyi(argv0: str) -> bool:
return os.path.basename(argv0).lower() in ALLOWED_RUYI_ENTRYPOINT_NAMES


CLIEntrypoint = Callable[[argparse.Namespace], int]
CLIEntrypoint = Callable[[GlobalConfig, argparse.Namespace], int]


def init_argparse() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -368,6 +368,6 @@ def main(argv: List[str]) -> int:
gc.telemetry.record("cli:invocation-v1", key=telemetry_key)

try:
return func(args)
return func(gc, args)
except Exception:
raise
11 changes: 8 additions & 3 deletions ruyi/cli/self_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@
"""


def cli_self_uninstall(args: argparse.Namespace) -> int:
def cli_self_uninstall(cfg: config.GlobalConfig, args: argparse.Namespace) -> int:
purge: bool = args.purge
consent: bool = args.consent
log.D(f"ruyi self uninstall: purge={purge}, consent={consent}")

if cfg.is_installation_externally_managed:
log.F(
"this [yellow]ruyi[/] is externally managed, for example, by the system package manager, and cannot be uninstalled this way"
)
log.I("please uninstall via the external manager instead")
return 1

if not ruyi.IS_PACKAGED:
log.F(
"this [yellow]ruyi[/yellow] is not in standalone form, and cannot be uninstalled this way"
Expand All @@ -40,8 +47,6 @@ def cli_self_uninstall(args: argparse.Namespace) -> int:
log.I("uninstallation consent given over CLI, proceeding")

if purge:
cfg = config.GlobalConfig.load_from_config()

log.I("removing installed packages")
shutil.rmtree(cfg.data_root, True)

Expand Down
11 changes: 9 additions & 2 deletions ruyi/cli/version_cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import argparse

from ..config import GlobalConfig
from .. import log
from ..version import COPYRIGHT_NOTICE, RUYI_SEMVER


def cli_version(_: argparse.Namespace) -> int:
def cli_version(gc: GlobalConfig, args: argparse.Namespace) -> int:
from ..ruyipkg.host import get_native_host

print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.\n")
print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.")

if gc.is_installation_externally_managed:
print("This Ruyi installation is externally managed.")

print()

log.stdout(COPYRIGHT_NOTICE)

return 0
63 changes: 54 additions & 9 deletions ruyi/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os.path
from os import PathLike
import pathlib
import sys
import tomllib
from typing import Any, Iterable, NotRequired, Self, TypedDict

Expand All @@ -10,6 +11,16 @@
from ..utils.xdg_basedir import XDGBaseDir
from .news import NewsReadStatusStore

PRESET_GLOBAL_CONFIG_LOCATIONS: list[str] = []

if sys.platform == "linux":
PRESET_GLOBAL_CONFIG_LOCATIONS = [
# TODO: enable distro packagers to customize the $PREFIX to suit their
# particular FS layout if necessary.
"/usr/share/ruyi/config.toml",
"/usr/local/share/ruyi/config.toml",
]

DEFAULT_APP_NAME = "ruyi"
DEFAULT_REPO_URL = "https://github.com/ruyisdk/packages-index.git"
DEFAULT_REPO_BRANCH = "main"
Expand Down Expand Up @@ -40,11 +51,21 @@ class GlobalConfigRepoType(TypedDict):
branch: NotRequired[str]


class GlobalConfigInstallationType(TypedDict):
# Undocumented: whether this Ruyi installation is externally managed.
#
# Can be used by distro packagers (by placing a config file in /etc/xdg/ruyi)
# to signify this status to an official Ruyi build (where IS_PACKAGED is
# True), to prevent e.g. accidental self-uninstallation.
externally_managed: NotRequired[bool]


class GlobalConfigTelemetryType(TypedDict):
mode: NotRequired[str]


class GlobalConfigRootType(TypedDict):
installation: NotRequired[GlobalConfigInstallationType]
packages: NotRequired[GlobalConfigPackagesType]
repo: NotRequired[GlobalConfigRepoType]
telemetry: NotRequired[GlobalConfigTelemetryType]
Expand All @@ -57,6 +78,7 @@ def __init__(self) -> None:
self.override_repo_url: str | None = None
self.override_repo_branch: str | None = None
self.include_prereleases = False
self.is_installation_externally_managed = False

self._news_read_status_store: NewsReadStatusStore | None = None
self._telemetry_store: TelemetryStore | None = None
Expand All @@ -68,6 +90,12 @@ def __init__(self) -> None:
self._telemetry_mode: str | None = None

def apply_config(self, config_data: GlobalConfigRootType) -> None:
if ins_cfg := config_data.get("installation"):
self.is_installation_externally_managed = ins_cfg.get(
"externally_managed",
False,
)

if pkgs_cfg := config_data.get("packages"):
self.include_prereleases = pkgs_cfg.get("prereleases", False)

Expand Down Expand Up @@ -178,6 +206,16 @@ def ensure_state_dir(self) -> os.PathLike[Any]:
p.mkdir(parents=True, exist_ok=True)
return p

def iter_preset_configs(self) -> Iterable[os.PathLike[Any]]:
"""
Yields possible Ruyi config files in all preset config path locations,
sorted by precedence from lowest to highest (so that each file may be
simply applied consecutively).
"""

for path in PRESET_GLOBAL_CONFIG_LOCATIONS:
yield pathlib.Path(path)

def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]:
"""
Yields possible Ruyi config files in all XDG config paths, sorted by precedence
Expand All @@ -187,20 +225,27 @@ def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]:
for config_dir in reversed(list(self._dirs.app_config_dirs)):
yield config_dir / "config.toml"

def try_apply_config_file(self, path: os.PathLike[Any]) -> None:
try:
with open(path, "rb") as fp:
data: Any = tomllib.load(fp)
except FileNotFoundError:
return

log.D(f"applying config: {data}")
self.apply_config(data)

@classmethod
def load_from_config(cls) -> Self:
obj = cls()

for config_path in obj.iter_preset_configs():
log.D(f"trying config file from preset location: {config_path}")
obj.try_apply_config_file(config_path)

for config_path in obj.iter_xdg_configs():
log.D(f"trying config file: {config_path}")
try:
with open(config_path, "rb") as fp:
data: Any = tomllib.load(fp)
except FileNotFoundError:
continue

log.D(f"applying config: {data}")
obj.apply_config(data)
log.D(f"trying config file from XDG path: {config_path}")
obj.try_apply_config_file(config_path)

# let environment variable take precedence
if is_env_var_truthy(ENV_TELEMETRY_OPTOUT_KEY):
Expand Down
7 changes: 3 additions & 4 deletions ruyi/device/provision_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,16 @@
from ..utils import prereqs


def cli_device_provision(args: argparse.Namespace) -> int:
def cli_device_provision(gc: GlobalConfig, args: argparse.Namespace) -> int:
try:
return do_provision_interactive()
return do_provision_interactive(gc)
except KeyboardInterrupt:
log.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n")
return 1


def do_provision_interactive() -> int:
def do_provision_interactive(config: GlobalConfig) -> int:
# ensure ruyi repo is present, for good out-of-the-box experience
config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)
mr.ensure_git_repo()

Expand Down
3 changes: 1 addition & 2 deletions ruyi/mux/venv/venv_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from .provision import render_template_str, VenvMaker


def cli_venv(args: argparse.Namespace) -> int:
def cli_venv(config: GlobalConfig, args: argparse.Namespace) -> int:
profile_name: str = args.profile
dest = pathlib.Path(args.dest)
with_sysroot: bool = args.with_sysroot
Expand All @@ -30,7 +30,6 @@ def cli_venv(args: argparse.Namespace) -> int:
)
return 1

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)

profile = mr.get_profile(profile_name)
Expand Down
6 changes: 2 additions & 4 deletions ruyi/ruyipkg/news_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ def print_news_item_titles(
log.stdout(tbl)


def cli_news_list(args: argparse.Namespace) -> int:
def cli_news_list(config: GlobalConfig, args: argparse.Namespace) -> int:
only_unread = args.new

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)
store = mr.news_store()
newsitems = store.list(only_unread)
Expand All @@ -58,11 +57,10 @@ def cli_news_list(args: argparse.Namespace) -> int:
return 0


def cli_news_read(args: argparse.Namespace) -> int:
def cli_news_read(config: GlobalConfig, args: argparse.Namespace) -> int:
quiet = args.quiet
items_strs = args.item

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)
store = mr.news_store()

Expand Down
9 changes: 3 additions & 6 deletions ruyi/ruyipkg/pkg_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
from .unpack import ensure_unpack_cmd_for_method


def cli_list(args: argparse.Namespace) -> int:
def cli_list(config: GlobalConfig, args: argparse.Namespace) -> int:
verbose = args.verbose

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)

augmented_pkgs = list(AugmentedPkg.yield_from_repo(mr))
Expand Down Expand Up @@ -210,12 +209,11 @@ def is_root_likely_populated(root: str) -> bool:
return False


def cli_extract(args: argparse.Namespace) -> int:
def cli_extract(config: GlobalConfig, args: argparse.Namespace) -> int:
host = args.host
atom_strs: set[str] = set(args.atom)
log.D(f"about to extract for host {host}: {atom_strs}")

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)

for a_str in atom_strs:
Expand Down Expand Up @@ -273,13 +271,12 @@ def cli_extract(args: argparse.Namespace) -> int:
return 0


def cli_install(args: argparse.Namespace) -> int:
def cli_install(config: GlobalConfig, args: argparse.Namespace) -> int:
host = args.host
atom_strs: set[str] = set(args.atom)
fetch_only = args.fetch_only
reinstall = args.reinstall

config = GlobalConfig.load_from_config()
mr = MetadataRepo(config)

return do_install_atoms(
Expand Down
3 changes: 1 addition & 2 deletions ruyi/ruyipkg/profile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
from .repo import MetadataRepo


def cli_list_profiles(args: argparse.Namespace) -> int:
config = GlobalConfig.load_from_config()
def cli_list_profiles(config: GlobalConfig, args: argparse.Namespace) -> int:
mr = MetadataRepo(config)

for arch in mr.get_supported_arches():
Expand Down
3 changes: 1 addition & 2 deletions ruyi/ruyipkg/update_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
from .repo import MetadataRepo


def cli_update(args: argparse.Namespace) -> int:
config = GlobalConfig.load_from_config()
def cli_update(config: GlobalConfig, args: argparse.Namespace) -> int:
mr = MetadataRepo(config)
mr.sync()

Expand Down

0 comments on commit 09f1e45

Please sign in to comment.