From c2e19ef0ef7973b9c3c7635b7fbb05e961731f60 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Sat, 2 Nov 2024 19:32:29 +0800 Subject: [PATCH 1/4] refactor(cli): init argparse in a hierarchical way And derive telemetry key automatically along the way. --- ruyi/cli/__init__.py | 914 +++++++++++++++++++++++++++---------------- 1 file changed, 569 insertions(+), 345 deletions(-) diff --git a/ruyi/cli/__init__.py b/ruyi/cli/__init__.py index 7670312..9705653 100644 --- a/ruyi/cli/__init__.py +++ b/ruyi/cli/__init__.py @@ -5,6 +5,7 @@ from typing import Callable, IO, List, Protocol from ..config import GlobalConfig +from ..version import RUYI_SEMVER # Should be all-lower for is_called_as_ruyi to work RUYI_ENTRYPOINT_NAME = "ruyi" @@ -36,350 +37,573 @@ def _wrapped_(gc: GlobalConfig, args: argparse.Namespace) -> int: return _wrapped_ -def init_argparse() -> argparse.ArgumentParser: - from ..device.provision_cli import cli_device_provision - from ..mux.venv.venv_cli import cli_venv - from ..ruyipkg.admin_cli import cli_admin_format_manifest, cli_admin_manifest - from ..ruyipkg.host import get_native_host - from ..ruyipkg.news_cli import cli_news_list, cli_news_read - from ..ruyipkg.pkg_cli import cli_extract, cli_install, cli_list - from ..ruyipkg.profile_cli import cli_list_profiles - from ..ruyipkg.update_cli import cli_update - from ..version import RUYI_SEMVER - from .self_cli import cli_self_clean, cli_self_uninstall - from .version_cli import cli_version - - native_host_str = get_native_host() - - root = argparse.ArgumentParser( - prog=RUYI_ENTRYPOINT_NAME, - description=f"RuyiSDK Package Manager {RUYI_SEMVER}", - ) - root.set_defaults(func=_wrap_help(root), tele_key="") - - root.add_argument( - "-V", - "--version", - action="store_const", - dest="func", - const=cli_version, - help="Print version information", - ) - - root.add_argument( - "--porcelain", - action="store_true", - help="Give the output in a machine-friendly format if applicable", - ) - - sp = root.add_subparsers( - title="subcommands", - ) - - # Device management commands - device = sp.add_parser( - "device", - help="Manage devices", - ) - device.set_defaults(func=_wrap_help(device), tele_key="device") - devicesp = device.add_subparsers( - title="subcommands", - ) - - device_provision = devicesp.add_parser( - "provision", - help="Interactively initialize a device for development", - ) - device_provision.set_defaults( - func=cli_device_provision, - tele_key="device provision", - ) - - extract = sp.add_parser( - "extract", - help="Fetch package(s) then extract to current directory", - ) - extract.add_argument( - "atom", - type=str, - nargs="+", - help="Specifier (atom) of the package(s) to extract", - ) - extract.add_argument( - "--host", - type=str, - default=native_host_str, - help="Override the host architecture (normally not needed)", - ) - extract.set_defaults(func=cli_extract, tele_key="extract") - - install = sp.add_parser( - "install", - aliases=["i"], - help="Install package from configured repository", - ) - install.add_argument( - "atom", - type=str, - nargs="+", - help="Specifier (atom) of the package to install", - ) - install.add_argument( - "-f", - "--fetch-only", - action="store_true", - help="Fetch distribution files only without installing", - ) - install.add_argument( - "--host", - type=str, - default=native_host_str, - help="Override the host architecture (normally not needed)", - ) - install.add_argument( - "--reinstall", - action="store_true", - help="Force re-installation of already installed packages", - ) - install.set_defaults(func=cli_install, tele_key="install") - - list = sp.add_parser( - "list", help="List available packages in configured repository" - ) - list.add_argument( - "--verbose", - "-v", - action="store_true", - help="Also show details for every package", - ) - list.set_defaults(func=cli_list, tele_key="list") - - listsp = list.add_subparsers(required=False) - list_profiles = listsp.add_parser("profiles", help="List all available profiles") - list_profiles.set_defaults(func=cli_list_profiles, tele_key="list profiles") - - news = sp.add_parser( - "news", - help="List and read news items from configured repository", - ) - news.set_defaults(func=_wrap_help(news), tele_key="news") - newssp = news.add_subparsers(title="subcommands") - - news_list = newssp.add_parser( - "list", - help="List news items", - ) - news_list.add_argument( - "--new", - action="store_true", - help="List unread news items only", - ) - news_list.set_defaults(func=cli_news_list, tele_key="news list") - - news_read = newssp.add_parser( - "read", - help="Read news items", - description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.", - ) - news_read.add_argument( - "--quiet", - "-q", - action="store_true", - help="Do not output anything and only mark as read", - ) - news_read.add_argument( - "item", - type=str, - nargs="*", - help="Ordinal or ID of the news item(s) to read", - ) - news_read.set_defaults(func=cli_news_read, tele_key="news read") - - up = sp.add_parser("update", help="Update RuyiSDK repo and packages") - up.set_defaults(func=cli_update, tele_key="update") - - venv = sp.add_parser( - "venv", - help="Generate a virtual environment adapted to the chosen toolchain and profile", - ) - venv.add_argument("profile", type=str, help="Profile to use for the environment") - venv.add_argument("dest", type=str, help="Path to the new virtual environment") - venv.add_argument( - "--name", - "-n", - type=str, - default=None, - help="Override the venv's name", - ) - venv.add_argument( - "--toolchain", - "-t", - type=str, - action="append", - help="Specifier(s) (atoms) of the toolchain package(s) to use", - ) - venv.add_argument( - "--emulator", - "-e", - type=str, - help="Specifier (atom) of the emulator package to use", - ) - venv.add_argument( - "--with-sysroot", - action="store_true", - dest="with_sysroot", - default=True, - help="Provision a fresh sysroot inside the new virtual environment (default)", - ) - venv.add_argument( - "--without-sysroot", - action="store_false", - dest="with_sysroot", - help="Do not include a sysroot inside the new virtual environment", - ) - venv.add_argument( - "--sysroot-from", - type=str, - help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable", - ) - venv.set_defaults(func=cli_venv, tele_key="venv") - - # Repo admin commands - admin = sp.add_parser( - "admin", - # https://github.com/python/cpython/issues/67037 - # help=argparse.SUPPRESS, - help="(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos", - ) - admin.set_defaults(func=_wrap_help(admin), tele_key="admin") - adminsp = admin.add_subparsers( - title="subcommands", - ) - - admin_format_manifest = adminsp.add_parser( - "format-manifest", - help="Format the given package manifests into canonical TOML representation", - ) - admin_format_manifest.add_argument( - "file", - type=str, - nargs="+", - help="Path to the distfile(s) to generate manifest for", - ) - admin_format_manifest.set_defaults( - func=cli_admin_format_manifest, - tele_key="admin format-manifest", - ) - - admin_manifest = adminsp.add_parser( - "manifest", - help="Generate manifest for the distfiles given", - ) - admin_manifest.add_argument( - "--format", - "-f", - type=str, - choices=["json", "toml"], - default="json", - help="Format of manifest to generate", - ) - admin_manifest.add_argument( - "--restrict", - type=str, - default="", - help="the 'restrict' field to use for all mentioned distfiles, separated with comma", - ) - admin_manifest.add_argument( - "file", - type=str, - nargs="+", - help="Path to the distfile(s) to generate manifest for", - ) - admin_manifest.set_defaults(func=cli_admin_manifest, tele_key="admin manifest") - - # Self-management commands - self = sp.add_parser( - "self", - help="Manage this Ruyi installation", - ) - self.set_defaults(func=_wrap_help(self), tele_key="self") - selfsp = self.add_subparsers( - title="subcommands", - ) - - self_clean = selfsp.add_parser( - "clean", - help="Remove various Ruyi-managed data to reclaim storage", - ) - self_clean.add_argument( - "--quiet", - "-q", - action="store_true", - help="Do not print out the actions being performed", - ) - self_clean.add_argument( - "--all", - action="store_true", - help="Remove all covered data", - ) - self_clean.add_argument( - "--distfiles", - action="store_true", - help="Remove all downloaded distfiles if any", - ) - self_clean.add_argument( - "--installed-pkgs", - action="store_true", - help="Remove all installed packages if any", - ) - self_clean.add_argument( - "--news-read-status", - action="store_true", - help="Mark all news items as unread", - ) - self_clean.add_argument( - "--progcache", - action="store_true", - help="Clear the Ruyi program cache", - ) - self_clean.add_argument( - "--repo", - action="store_true", - help="Remove the Ruyi repo if located in Ruyi-managed cache directory", - ) - self_clean.add_argument( - "--telemetry", - action="store_true", - help="Remove all telemetry data recorded if any", - ) - self_clean.set_defaults(func=cli_self_clean, tele_key="self clean") - - self_uninstall = selfsp.add_parser( - "uninstall", - help="Uninstall Ruyi", - ) - self_uninstall.add_argument( - "--purge", - action="store_true", - help="Remove all installed packages and Ruyi-managed remote repo data", - ) - self_uninstall.add_argument( - "-y", - action="store_true", - dest="consent", - help="Give consent for uninstallation on CLI; do not ask for confirmation", - ) - self_uninstall.set_defaults(func=cli_self_uninstall, tele_key="self uninstall") - - # Version info - # Keep this at the bottom - version = sp.add_parser( - "version", - help="Print version information", - ) - version.set_defaults(func=cli_version, tele_key="version") - - return root +class BaseCommand: + parsers: "list[type[BaseCommand]]" = [] + + cmd: str | None + _tele_key: str | None + has_subcommands: bool + is_subcommand_required: bool + has_main: bool + aliases: list[str] + description: str | None + prog: str | None + help: str | None + + def __init_subclass__( + cls, + cmd: str | None, + has_subcommands: bool = False, + is_subcommand_required: bool = False, + has_main: bool | None = None, + aliases: list[str] | None = None, + description: str | None = None, + prog: str | None = None, + help: str | None = None, + **kwargs: object, + ) -> None: + cls.cmd = cmd + + if cmd is None: + cls._tele_key = None + else: + parent_raw_tele_key = cls.mro()[1]._tele_key + if parent_raw_tele_key is None: + cls._tele_key = cmd + else: + cls._tele_key = f"{parent_raw_tele_key} {cmd}" + + cls.has_subcommands = has_subcommands + cls.is_subcommand_required = is_subcommand_required + cls.has_main = has_main if has_main is not None else not has_subcommands + + # argparse params + cls.aliases = aliases or [] + cls.description = description + cls.prog = prog + cls.help = help + + cls.parsers.append(cls) + + super().__init_subclass__(**kwargs) + + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + """Configure arguments for this parser.""" + pass + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + """Entrypoint of this command.""" + raise NotImplementedError + + @classmethod + def is_root(cls) -> bool: + return cls.cmd is None + + @classmethod + def _build_tele_key(cls) -> str: + return "" if cls._tele_key is None else cls._tele_key + + @classmethod + def build_argparse(cls) -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog=cls.prog, description=cls.description) + cls.configure_args(p) + cls._populate_defaults(p) + cls._maybe_build_subcommands(p) + return p + + @classmethod + def _maybe_build_subcommands( + cls, + p: argparse.ArgumentParser, + ) -> None: + if not cls.has_subcommands: + return + + sp = p.add_subparsers( + title="subcommands", + required=cls.is_subcommand_required, + ) + for subcmd_cls in cls.parsers: + if subcmd_cls.mro()[1] is not cls: + # do not recurse onto self or non-direct subclasses + continue + subcmd_cls._configure_subcommand(sp) + + @classmethod + def _configure_subcommand( + cls, + sp: "argparse._SubParsersAction[argparse.ArgumentParser]", + ) -> argparse.ArgumentParser: + assert cls.cmd is not None + p = sp.add_parser( + cls.cmd, + aliases=cls.aliases, + help=cls.help, + ) + cls.configure_args(p) + cls._populate_defaults(p) + cls._maybe_build_subcommands(p) + return p + + @classmethod + def _populate_defaults(cls, p: argparse.ArgumentParser) -> None: + if cls.has_main: + p.set_defaults(func=cls.main, tele_key=cls._build_tele_key()) + else: + p.set_defaults(func=_wrap_help(p), tele_key=cls._build_tele_key()) + + +class RootCommand( + BaseCommand, + cmd=None, + has_subcommands=True, + prog=RUYI_ENTRYPOINT_NAME, + description=f"RuyiSDK Package Manager {RUYI_SEMVER}", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + from .version_cli import cli_version + + p.add_argument( + "-V", + "--version", + action="store_const", + dest="func", + const=cli_version, + help="Print version information", + ) + p.add_argument( + "--porcelain", + action="store_true", + help="Give the output in a machine-friendly format if applicable", + ) + + +class DeviceCommand( + RootCommand, + cmd="device", + has_subcommands=True, + help="Manage devices", +): + pass + + +class DeviceProvisionCommand( + DeviceCommand, + cmd="provision", + help="Interactively initialize a device for development", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..device.provision_cli import cli_device_provision + + return cli_device_provision(cfg, args) + + +class ExtractCommand( + RootCommand, + cmd="extract", + help="Fetch package(s) then extract to current directory", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + from ..ruyipkg.host import get_native_host + + p.add_argument( + "atom", + type=str, + nargs="+", + help="Specifier (atom) of the package(s) to extract", + ) + p.add_argument( + "--host", + type=str, + default=get_native_host(), + help="Override the host architecture (normally not needed)", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.pkg_cli import cli_extract + + return cli_extract(cfg, args) + + +class InstallCommand( + RootCommand, + cmd="install", + aliases=["i"], + help="Install package from configured repository", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + from ..ruyipkg.host import get_native_host + + p.add_argument( + "atom", + type=str, + nargs="+", + help="Specifier (atom) of the package to install", + ) + p.add_argument( + "-f", + "--fetch-only", + action="store_true", + help="Fetch distribution files only without installing", + ) + p.add_argument( + "--host", + type=str, + default=get_native_host(), + help="Override the host architecture (normally not needed)", + ) + p.add_argument( + "--reinstall", + action="store_true", + help="Force re-installation of already installed packages", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.pkg_cli import cli_install + + return cli_install(cfg, args) + + +class ListCommand( + RootCommand, + cmd="list", + has_subcommands=True, + is_subcommand_required=False, + has_main=True, + help="List available packages in configured repository", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--verbose", + "-v", + action="store_true", + help="Also show details for every package", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.pkg_cli import cli_list + + return cli_list(cfg, args) + + +class ListProfilesCommand( + ListCommand, + cmd="profiles", + help="List all available profiles", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.profile_cli import cli_list_profiles + + return cli_list_profiles(cfg, args) + + +class NewsCommand( + RootCommand, + cmd="news", + has_subcommands=True, + help="List and read news items from configured repository", +): + pass + + +class NewsListCommand( + NewsCommand, + cmd="list", + help="List news items", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--new", + action="store_true", + help="List unread news items only", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.news_cli import cli_news_list + + return cli_news_list(cfg, args) + + +class NewsReadCommand( + NewsCommand, + cmd="read", + help="Read news items", + description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--quiet", + "-q", + action="store_true", + help="Do not output anything and only mark as read", + ) + p.add_argument( + "item", + type=str, + nargs="*", + help="Ordinal or ID of the news item(s) to read", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.news_cli import cli_news_read + + return cli_news_read(cfg, args) + + +class UpdateCommand( + RootCommand, + cmd="update", + help="Update RuyiSDK repo and packages", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.update_cli import cli_update + + return cli_update(cfg, args) + + +class VenvCommand( + RootCommand, + cmd="venv", + help="Generate a virtual environment adapted to the chosen toolchain and profile", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument("profile", type=str, help="Profile to use for the environment") + p.add_argument("dest", type=str, help="Path to the new virtual environment") + p.add_argument( + "--name", + "-n", + type=str, + default=None, + help="Override the venv's name", + ) + p.add_argument( + "--toolchain", + "-t", + type=str, + action="append", + help="Specifier(s) (atoms) of the toolchain package(s) to use", + ) + p.add_argument( + "--emulator", + "-e", + type=str, + help="Specifier (atom) of the emulator package to use", + ) + p.add_argument( + "--with-sysroot", + action="store_true", + dest="with_sysroot", + default=True, + help="Provision a fresh sysroot inside the new virtual environment (default)", + ) + p.add_argument( + "--without-sysroot", + action="store_false", + dest="with_sysroot", + help="Do not include a sysroot inside the new virtual environment", + ) + p.add_argument( + "--sysroot-from", + type=str, + help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..mux.venv.venv_cli import cli_venv + + return cli_venv(cfg, args) + + +# Repo admin commands +class AdminCommand( + RootCommand, + cmd="admin", + has_subcommands=True, + # https://github.com/python/cpython/issues/67037 + # help=argparse.SUPPRESS, + help="(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos", +): + pass + + +class AdminFormatManifestCommand( + AdminCommand, + cmd="format-manifest", + help="Format the given package manifests into canonical TOML representation", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "file", + type=str, + nargs="+", + help="Path to the distfile(s) to generate manifest for", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.admin_cli import cli_admin_format_manifest + + return cli_admin_format_manifest(cfg, args) + + +class AdminManifestCommand( + AdminCommand, + cmd="manifest", + help="Generate manifest for the distfiles given", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--format", + "-f", + type=str, + choices=["json", "toml"], + default="json", + help="Format of manifest to generate", + ) + p.add_argument( + "--restrict", + type=str, + default="", + help="the 'restrict' field to use for all mentioned distfiles, separated with comma", + ) + p.add_argument( + "file", + type=str, + nargs="+", + help="Path to the distfile(s) to generate manifest for", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from ..ruyipkg.admin_cli import cli_admin_manifest + + return cli_admin_manifest(cfg, args) + + +# Self-management commands +class SelfCommand( + RootCommand, + cmd="self", + has_subcommands=True, + help="Manage this Ruyi installation", +): + pass + + +class SelfCleanCommand( + SelfCommand, + cmd="clean", + help="Remove various Ruyi-managed data to reclaim storage", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--quiet", + "-q", + action="store_true", + help="Do not print out the actions being performed", + ) + p.add_argument( + "--all", + action="store_true", + help="Remove all covered data", + ) + p.add_argument( + "--distfiles", + action="store_true", + help="Remove all downloaded distfiles if any", + ) + p.add_argument( + "--installed-pkgs", + action="store_true", + help="Remove all installed packages if any", + ) + p.add_argument( + "--news-read-status", + action="store_true", + help="Mark all news items as unread", + ) + p.add_argument( + "--progcache", + action="store_true", + help="Clear the Ruyi program cache", + ) + p.add_argument( + "--repo", + action="store_true", + help="Remove the Ruyi repo if located in Ruyi-managed cache directory", + ) + p.add_argument( + "--telemetry", + action="store_true", + help="Remove all telemetry data recorded if any", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from .self_cli import cli_self_clean + + return cli_self_clean(cfg, args) + + +class SelfUninstallCommand( + SelfCommand, + cmd="uninstall", + help="Uninstall Ruyi", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--purge", + action="store_true", + help="Remove all installed packages and Ruyi-managed remote repo data", + ) + p.add_argument( + "-y", + action="store_true", + dest="consent", + help="Give consent for uninstallation on CLI; do not ask for confirmation", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from .self_cli import cli_self_uninstall + + return cli_self_uninstall(cfg, args) + + +# Version info +# Keep this at the bottom +class VersionCommand( + RootCommand, + cmd="version", + help="Print version information", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + from .version_cli import cli_version + + return cli_version(cfg, args) def main(argv: List[str]) -> int: @@ -401,7 +625,7 @@ def main(argv: List[str]) -> int: import ruyi from .. import log - p = init_argparse() + p = RootCommand.build_argparse() args = p.parse_args(argv[1:]) ruyi.set_porcelain(args.porcelain) From b596224af59dfd360004e6681343e56037c6e194 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Sat, 2 Nov 2024 20:05:21 +0800 Subject: [PATCH 2/4] refactor(cli): move command classes out of ruyi.cli --- ruyi/cli/__init__.py | 593 +---------------------------------- ruyi/cli/builtin_commands.py | 9 + ruyi/cli/cmd.py | 164 ++++++++++ ruyi/cli/self_cli.py | 219 +++++++++---- ruyi/cli/version_cli.py | 17 +- ruyi/device/provision_cli.py | 28 +- ruyi/mux/venv/venv_cli.py | 54 ++++ ruyi/ruyipkg/admin_cli.py | 129 +++++--- ruyi/ruyipkg/news_cli.py | 118 ++++--- ruyi/ruyipkg/pkg_cli.py | 239 +++++++++----- ruyi/ruyipkg/profile_cli.py | 25 +- ruyi/ruyipkg/update_cli.py | 27 +- 12 files changed, 782 insertions(+), 840 deletions(-) create mode 100644 ruyi/cli/builtin_commands.py create mode 100644 ruyi/cli/cmd.py diff --git a/ruyi/cli/__init__.py b/ruyi/cli/__init__.py index 9705653..dbe26f7 100644 --- a/ruyi/cli/__init__.py +++ b/ruyi/cli/__init__.py @@ -1,11 +1,8 @@ -import argparse import atexit import os import sys -from typing import Callable, IO, List, Protocol from ..config import GlobalConfig -from ..version import RUYI_SEMVER # Should be all-lower for is_called_as_ruyi to work RUYI_ENTRYPOINT_NAME = "ruyi" @@ -22,591 +19,7 @@ def is_called_as_ruyi(argv0: str) -> bool: return os.path.basename(argv0).lower() in ALLOWED_RUYI_ENTRYPOINT_NAMES -CLIEntrypoint = Callable[[GlobalConfig, argparse.Namespace], int] - - -class _PrintHelp(Protocol): - def print_help(self, file: IO[str] | None = None) -> None: ... - - -def _wrap_help(x: _PrintHelp) -> CLIEntrypoint: - def _wrapped_(gc: GlobalConfig, args: argparse.Namespace) -> int: - x.print_help() - return 0 - - return _wrapped_ - - -class BaseCommand: - parsers: "list[type[BaseCommand]]" = [] - - cmd: str | None - _tele_key: str | None - has_subcommands: bool - is_subcommand_required: bool - has_main: bool - aliases: list[str] - description: str | None - prog: str | None - help: str | None - - def __init_subclass__( - cls, - cmd: str | None, - has_subcommands: bool = False, - is_subcommand_required: bool = False, - has_main: bool | None = None, - aliases: list[str] | None = None, - description: str | None = None, - prog: str | None = None, - help: str | None = None, - **kwargs: object, - ) -> None: - cls.cmd = cmd - - if cmd is None: - cls._tele_key = None - else: - parent_raw_tele_key = cls.mro()[1]._tele_key - if parent_raw_tele_key is None: - cls._tele_key = cmd - else: - cls._tele_key = f"{parent_raw_tele_key} {cmd}" - - cls.has_subcommands = has_subcommands - cls.is_subcommand_required = is_subcommand_required - cls.has_main = has_main if has_main is not None else not has_subcommands - - # argparse params - cls.aliases = aliases or [] - cls.description = description - cls.prog = prog - cls.help = help - - cls.parsers.append(cls) - - super().__init_subclass__(**kwargs) - - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - """Configure arguments for this parser.""" - pass - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - """Entrypoint of this command.""" - raise NotImplementedError - - @classmethod - def is_root(cls) -> bool: - return cls.cmd is None - - @classmethod - def _build_tele_key(cls) -> str: - return "" if cls._tele_key is None else cls._tele_key - - @classmethod - def build_argparse(cls) -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog=cls.prog, description=cls.description) - cls.configure_args(p) - cls._populate_defaults(p) - cls._maybe_build_subcommands(p) - return p - - @classmethod - def _maybe_build_subcommands( - cls, - p: argparse.ArgumentParser, - ) -> None: - if not cls.has_subcommands: - return - - sp = p.add_subparsers( - title="subcommands", - required=cls.is_subcommand_required, - ) - for subcmd_cls in cls.parsers: - if subcmd_cls.mro()[1] is not cls: - # do not recurse onto self or non-direct subclasses - continue - subcmd_cls._configure_subcommand(sp) - - @classmethod - def _configure_subcommand( - cls, - sp: "argparse._SubParsersAction[argparse.ArgumentParser]", - ) -> argparse.ArgumentParser: - assert cls.cmd is not None - p = sp.add_parser( - cls.cmd, - aliases=cls.aliases, - help=cls.help, - ) - cls.configure_args(p) - cls._populate_defaults(p) - cls._maybe_build_subcommands(p) - return p - - @classmethod - def _populate_defaults(cls, p: argparse.ArgumentParser) -> None: - if cls.has_main: - p.set_defaults(func=cls.main, tele_key=cls._build_tele_key()) - else: - p.set_defaults(func=_wrap_help(p), tele_key=cls._build_tele_key()) - - -class RootCommand( - BaseCommand, - cmd=None, - has_subcommands=True, - prog=RUYI_ENTRYPOINT_NAME, - description=f"RuyiSDK Package Manager {RUYI_SEMVER}", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - from .version_cli import cli_version - - p.add_argument( - "-V", - "--version", - action="store_const", - dest="func", - const=cli_version, - help="Print version information", - ) - p.add_argument( - "--porcelain", - action="store_true", - help="Give the output in a machine-friendly format if applicable", - ) - - -class DeviceCommand( - RootCommand, - cmd="device", - has_subcommands=True, - help="Manage devices", -): - pass - - -class DeviceProvisionCommand( - DeviceCommand, - cmd="provision", - help="Interactively initialize a device for development", -): - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..device.provision_cli import cli_device_provision - - return cli_device_provision(cfg, args) - - -class ExtractCommand( - RootCommand, - cmd="extract", - help="Fetch package(s) then extract to current directory", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - from ..ruyipkg.host import get_native_host - - p.add_argument( - "atom", - type=str, - nargs="+", - help="Specifier (atom) of the package(s) to extract", - ) - p.add_argument( - "--host", - type=str, - default=get_native_host(), - help="Override the host architecture (normally not needed)", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.pkg_cli import cli_extract - - return cli_extract(cfg, args) - - -class InstallCommand( - RootCommand, - cmd="install", - aliases=["i"], - help="Install package from configured repository", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - from ..ruyipkg.host import get_native_host - - p.add_argument( - "atom", - type=str, - nargs="+", - help="Specifier (atom) of the package to install", - ) - p.add_argument( - "-f", - "--fetch-only", - action="store_true", - help="Fetch distribution files only without installing", - ) - p.add_argument( - "--host", - type=str, - default=get_native_host(), - help="Override the host architecture (normally not needed)", - ) - p.add_argument( - "--reinstall", - action="store_true", - help="Force re-installation of already installed packages", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.pkg_cli import cli_install - - return cli_install(cfg, args) - - -class ListCommand( - RootCommand, - cmd="list", - has_subcommands=True, - is_subcommand_required=False, - has_main=True, - help="List available packages in configured repository", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--verbose", - "-v", - action="store_true", - help="Also show details for every package", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.pkg_cli import cli_list - - return cli_list(cfg, args) - - -class ListProfilesCommand( - ListCommand, - cmd="profiles", - help="List all available profiles", -): - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.profile_cli import cli_list_profiles - - return cli_list_profiles(cfg, args) - - -class NewsCommand( - RootCommand, - cmd="news", - has_subcommands=True, - help="List and read news items from configured repository", -): - pass - - -class NewsListCommand( - NewsCommand, - cmd="list", - help="List news items", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--new", - action="store_true", - help="List unread news items only", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.news_cli import cli_news_list - - return cli_news_list(cfg, args) - - -class NewsReadCommand( - NewsCommand, - cmd="read", - help="Read news items", - description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--quiet", - "-q", - action="store_true", - help="Do not output anything and only mark as read", - ) - p.add_argument( - "item", - type=str, - nargs="*", - help="Ordinal or ID of the news item(s) to read", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.news_cli import cli_news_read - - return cli_news_read(cfg, args) - - -class UpdateCommand( - RootCommand, - cmd="update", - help="Update RuyiSDK repo and packages", -): - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.update_cli import cli_update - - return cli_update(cfg, args) - - -class VenvCommand( - RootCommand, - cmd="venv", - help="Generate a virtual environment adapted to the chosen toolchain and profile", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument("profile", type=str, help="Profile to use for the environment") - p.add_argument("dest", type=str, help="Path to the new virtual environment") - p.add_argument( - "--name", - "-n", - type=str, - default=None, - help="Override the venv's name", - ) - p.add_argument( - "--toolchain", - "-t", - type=str, - action="append", - help="Specifier(s) (atoms) of the toolchain package(s) to use", - ) - p.add_argument( - "--emulator", - "-e", - type=str, - help="Specifier (atom) of the emulator package to use", - ) - p.add_argument( - "--with-sysroot", - action="store_true", - dest="with_sysroot", - default=True, - help="Provision a fresh sysroot inside the new virtual environment (default)", - ) - p.add_argument( - "--without-sysroot", - action="store_false", - dest="with_sysroot", - help="Do not include a sysroot inside the new virtual environment", - ) - p.add_argument( - "--sysroot-from", - type=str, - help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..mux.venv.venv_cli import cli_venv - - return cli_venv(cfg, args) - - -# Repo admin commands -class AdminCommand( - RootCommand, - cmd="admin", - has_subcommands=True, - # https://github.com/python/cpython/issues/67037 - # help=argparse.SUPPRESS, - help="(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos", -): - pass - - -class AdminFormatManifestCommand( - AdminCommand, - cmd="format-manifest", - help="Format the given package manifests into canonical TOML representation", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "file", - type=str, - nargs="+", - help="Path to the distfile(s) to generate manifest for", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.admin_cli import cli_admin_format_manifest - - return cli_admin_format_manifest(cfg, args) - - -class AdminManifestCommand( - AdminCommand, - cmd="manifest", - help="Generate manifest for the distfiles given", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--format", - "-f", - type=str, - choices=["json", "toml"], - default="json", - help="Format of manifest to generate", - ) - p.add_argument( - "--restrict", - type=str, - default="", - help="the 'restrict' field to use for all mentioned distfiles, separated with comma", - ) - p.add_argument( - "file", - type=str, - nargs="+", - help="Path to the distfile(s) to generate manifest for", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from ..ruyipkg.admin_cli import cli_admin_manifest - - return cli_admin_manifest(cfg, args) - - -# Self-management commands -class SelfCommand( - RootCommand, - cmd="self", - has_subcommands=True, - help="Manage this Ruyi installation", -): - pass - - -class SelfCleanCommand( - SelfCommand, - cmd="clean", - help="Remove various Ruyi-managed data to reclaim storage", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--quiet", - "-q", - action="store_true", - help="Do not print out the actions being performed", - ) - p.add_argument( - "--all", - action="store_true", - help="Remove all covered data", - ) - p.add_argument( - "--distfiles", - action="store_true", - help="Remove all downloaded distfiles if any", - ) - p.add_argument( - "--installed-pkgs", - action="store_true", - help="Remove all installed packages if any", - ) - p.add_argument( - "--news-read-status", - action="store_true", - help="Mark all news items as unread", - ) - p.add_argument( - "--progcache", - action="store_true", - help="Clear the Ruyi program cache", - ) - p.add_argument( - "--repo", - action="store_true", - help="Remove the Ruyi repo if located in Ruyi-managed cache directory", - ) - p.add_argument( - "--telemetry", - action="store_true", - help="Remove all telemetry data recorded if any", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from .self_cli import cli_self_clean - - return cli_self_clean(cfg, args) - - -class SelfUninstallCommand( - SelfCommand, - cmd="uninstall", - help="Uninstall Ruyi", -): - @classmethod - def configure_args(cls, p: argparse.ArgumentParser) -> None: - p.add_argument( - "--purge", - action="store_true", - help="Remove all installed packages and Ruyi-managed remote repo data", - ) - p.add_argument( - "-y", - action="store_true", - dest="consent", - help="Give consent for uninstallation on CLI; do not ask for confirmation", - ) - - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from .self_cli import cli_self_uninstall - - return cli_self_uninstall(cfg, args) - - -# Version info -# Keep this at the bottom -class VersionCommand( - RootCommand, - cmd="version", - help="Print version information", -): - @classmethod - def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: - from .version_cli import cli_version - - return cli_version(cfg, args) - - -def main(argv: List[str]) -> int: +def main(argv: list[str]) -> int: gc = GlobalConfig.load_from_config() if gc.telemetry is not None: gc.telemetry.init_installation(False) @@ -624,6 +37,10 @@ def main(argv: List[str]) -> int: import ruyi from .. import log + from .cmd import CLIEntrypoint, RootCommand + from . import builtin_commands + + del builtin_commands p = RootCommand.build_argparse() args = p.parse_args(argv[1:]) diff --git a/ruyi/cli/builtin_commands.py b/ruyi/cli/builtin_commands.py new file mode 100644 index 0000000..db504f2 --- /dev/null +++ b/ruyi/cli/builtin_commands.py @@ -0,0 +1,9 @@ +from ..device import provision_cli as provision_cli +from ..mux.venv import venv_cli as venv_cli +from ..ruyipkg import admin_cli as admin_cli +from ..ruyipkg import news_cli as news_cli +from ..ruyipkg import pkg_cli as pkg_cli +from ..ruyipkg import profile_cli as profile_cli +from ..ruyipkg import update_cli as update_cli +from . import self_cli as self_cli +from . import version_cli as version_cli diff --git a/ruyi/cli/cmd.py b/ruyi/cli/cmd.py new file mode 100644 index 0000000..00ad3f7 --- /dev/null +++ b/ruyi/cli/cmd.py @@ -0,0 +1,164 @@ +import argparse +from typing import Callable, IO, Protocol + +from ..config import GlobalConfig +from ..version import RUYI_SEMVER +from . import RUYI_ENTRYPOINT_NAME + +CLIEntrypoint = Callable[[GlobalConfig, argparse.Namespace], int] + + +class _PrintHelp(Protocol): + def print_help(self, file: IO[str] | None = None) -> None: ... + + +def _wrap_help(x: _PrintHelp) -> CLIEntrypoint: + def _wrapped_(gc: GlobalConfig, args: argparse.Namespace) -> int: + x.print_help() + return 0 + + return _wrapped_ + + +class BaseCommand: + parsers: "list[type[BaseCommand]]" = [] + + cmd: str | None + _tele_key: str | None + has_subcommands: bool + is_subcommand_required: bool + has_main: bool + aliases: list[str] + description: str | None + prog: str | None + help: str | None + + def __init_subclass__( + cls, + cmd: str | None, + has_subcommands: bool = False, + is_subcommand_required: bool = False, + has_main: bool | None = None, + aliases: list[str] | None = None, + description: str | None = None, + prog: str | None = None, + help: str | None = None, + **kwargs: object, + ) -> None: + cls.cmd = cmd + + if cmd is None: + cls._tele_key = None + else: + parent_raw_tele_key = cls.mro()[1]._tele_key + if parent_raw_tele_key is None: + cls._tele_key = cmd + else: + cls._tele_key = f"{parent_raw_tele_key} {cmd}" + + cls.has_subcommands = has_subcommands + cls.is_subcommand_required = is_subcommand_required + cls.has_main = has_main if has_main is not None else not has_subcommands + + # argparse params + cls.aliases = aliases or [] + cls.description = description + cls.prog = prog + cls.help = help + + cls.parsers.append(cls) + + super().__init_subclass__(**kwargs) + + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + """Configure arguments for this parser.""" + pass + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + """Entrypoint of this command.""" + raise NotImplementedError + + @classmethod + def is_root(cls) -> bool: + return cls.cmd is None + + @classmethod + def _build_tele_key(cls) -> str: + return "" if cls._tele_key is None else cls._tele_key + + @classmethod + def build_argparse(cls) -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog=cls.prog, description=cls.description) + cls.configure_args(p) + cls._populate_defaults(p) + cls._maybe_build_subcommands(p) + return p + + @classmethod + def _maybe_build_subcommands( + cls, + p: argparse.ArgumentParser, + ) -> None: + if not cls.has_subcommands: + return + + sp = p.add_subparsers( + title="subcommands", + required=cls.is_subcommand_required, + ) + for subcmd_cls in cls.parsers: + if subcmd_cls.mro()[1] is not cls: + # do not recurse onto self or non-direct subclasses + continue + subcmd_cls._configure_subcommand(sp) + + @classmethod + def _configure_subcommand( + cls, + sp: "argparse._SubParsersAction[argparse.ArgumentParser]", + ) -> argparse.ArgumentParser: + assert cls.cmd is not None + p = sp.add_parser( + cls.cmd, + aliases=cls.aliases, + help=cls.help, + ) + cls.configure_args(p) + cls._populate_defaults(p) + cls._maybe_build_subcommands(p) + return p + + @classmethod + def _populate_defaults(cls, p: argparse.ArgumentParser) -> None: + if cls.has_main: + p.set_defaults(func=cls.main, tele_key=cls._build_tele_key()) + else: + p.set_defaults(func=_wrap_help(p), tele_key=cls._build_tele_key()) + + +class RootCommand( + BaseCommand, + cmd=None, + has_subcommands=True, + prog=RUYI_ENTRYPOINT_NAME, + description=f"RuyiSDK Package Manager {RUYI_SEMVER}", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + from .version_cli import cli_version + + p.add_argument( + "-V", + "--version", + action="store_const", + dest="func", + const=cli_version, + help="Print version information", + ) + p.add_argument( + "--porcelain", + action="store_true", + help="Give the output in a machine-friendly format if applicable", + ) diff --git a/ruyi/cli/self_cli.py b/ruyi/cli/self_cli.py index ab30c93..c6cf783 100644 --- a/ruyi/cli/self_cli.py +++ b/ruyi/cli/self_cli.py @@ -7,6 +7,7 @@ from .. import config from .. import log from . import user_input +from .cmd import RootCommand UNINSTALL_NOTICE = """ [bold]Thanks for hacking with [yellow]Ruyi[/yellow]![/bold] @@ -21,83 +22,163 @@ """ -def cli_self_clean(cfg: config.GlobalConfig, args: argparse.Namespace) -> int: - quiet: bool = args.quiet - all: bool = args.all - distfiles: bool = args.distfiles - installed_pkgs: bool = args.installed_pkgs - news_read_status: bool = args.news_read_status - progcache: bool = args.progcache - repo: bool = args.repo - telemetry: bool = args.telemetry - - if all: - distfiles = True - installed_pkgs = True - news_read_status = True - progcache = True - repo = True - telemetry = True - - if not any([distfiles, installed_pkgs, news_read_status, progcache, repo, telemetry]): - log.F("no data specified for cleaning") - log.I( - "please check [yellow]ruyi self clean --help[/] for a list of cleanable data" +# Self-management commands +class SelfCommand( + RootCommand, + cmd="self", + has_subcommands=True, + help="Manage this Ruyi installation", +): + pass + + +class SelfCleanCommand( + SelfCommand, + cmd="clean", + help="Remove various Ruyi-managed data to reclaim storage", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--quiet", + "-q", + action="store_true", + help="Do not print out the actions being performed", + ) + p.add_argument( + "--all", + action="store_true", + help="Remove all covered data", + ) + p.add_argument( + "--distfiles", + action="store_true", + help="Remove all downloaded distfiles if any", + ) + p.add_argument( + "--installed-pkgs", + action="store_true", + help="Remove all installed packages if any", + ) + p.add_argument( + "--news-read-status", + action="store_true", + help="Mark all news items as unread", ) - return 1 - - _do_reset( - cfg, - quiet=quiet, - distfiles=distfiles, - installed_pkgs=installed_pkgs, - news_read_status=news_read_status, - progcache=progcache, - repo=repo, - telemetry=telemetry, - ) - - return 0 - - -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" + p.add_argument( + "--progcache", + action="store_true", + help="Clear the Ruyi program cache", ) - log.I("please uninstall via the external manager instead") - return 1 + p.add_argument( + "--repo", + action="store_true", + help="Remove the Ruyi repo if located in Ruyi-managed cache directory", + ) + p.add_argument( + "--telemetry", + action="store_true", + help="Remove all telemetry data recorded if any", + ) + + @classmethod + def main(cls, cfg: config.GlobalConfig, args: argparse.Namespace) -> int: + quiet: bool = args.quiet + all: bool = args.all + distfiles: bool = args.distfiles + installed_pkgs: bool = args.installed_pkgs + news_read_status: bool = args.news_read_status + progcache: bool = args.progcache + repo: bool = args.repo + telemetry: bool = args.telemetry + + if all: + distfiles = True + installed_pkgs = True + news_read_status = True + progcache = True + repo = True + telemetry = True + + if not any([distfiles, installed_pkgs, news_read_status, progcache, repo, telemetry]): + log.F("no data specified for cleaning") + log.I( + "please check [yellow]ruyi self clean --help[/] for a list of cleanable data" + ) + return 1 - if not ruyi.IS_PACKAGED: - log.F( - "this [yellow]ruyi[/yellow] is not in standalone form, and cannot be uninstalled this way" + _do_reset( + cfg, + quiet=quiet, + distfiles=distfiles, + installed_pkgs=installed_pkgs, + news_read_status=news_read_status, + progcache=progcache, + repo=repo, + telemetry=telemetry, ) - return 1 - if not consent: - log.stdout(UNINSTALL_NOTICE) - if not user_input.ask_for_yesno_confirmation("Continue?"): - log.I("aborting uninstallation") - return 0 - else: - log.I("uninstallation consent given over CLI, proceeding") + return 0 + + +class SelfUninstallCommand( + SelfCommand, + cmd="uninstall", + help="Uninstall Ruyi", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--purge", + action="store_true", + help="Remove all installed packages and Ruyi-managed remote repo data", + ) + p.add_argument( + "-y", + action="store_true", + dest="consent", + help="Give consent for uninstallation on CLI; do not ask for confirmation", + ) - _do_reset( - cfg, - quiet=False, - installed_pkgs=purge, - all_state=purge, - all_cache=purge, - self_binary=True, - ) + @classmethod + def main(cls, 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" + ) + return 1 + + if not consent: + log.stdout(UNINSTALL_NOTICE) + if not user_input.ask_for_yesno_confirmation("Continue?"): + log.I("aborting uninstallation") + return 0 + else: + log.I("uninstallation consent given over CLI, proceeding") + + _do_reset( + cfg, + quiet=False, + installed_pkgs=purge, + all_state=purge, + all_cache=purge, + self_binary=True, + ) - log.I("[yellow]ruyi[/yellow] is uninstalled") + log.I("[yellow]ruyi[/yellow] is uninstalled") - return 0 + return 0 def _do_reset( diff --git a/ruyi/cli/version_cli.py b/ruyi/cli/version_cli.py index 49f689a..3f63f77 100644 --- a/ruyi/cli/version_cli.py +++ b/ruyi/cli/version_cli.py @@ -1,16 +1,27 @@ import argparse -from ..config import GlobalConfig from .. import log +from ..config import GlobalConfig from ..version import COPYRIGHT_NOTICE, RUYI_SEMVER +from .cmd import RootCommand + + +class VersionCommand( + RootCommand, + cmd="version", + help="Print version information", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + return cli_version(cfg, args) -def cli_version(gc: GlobalConfig, args: argparse.Namespace) -> int: +def cli_version(cfg: GlobalConfig, args: argparse.Namespace) -> int: from ..ruyipkg.host import get_native_host print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.") - if gc.is_installation_externally_managed: + if cfg.is_installation_externally_managed: print("This Ruyi installation is externally managed.") print() diff --git a/ruyi/device/provision_cli.py b/ruyi/device/provision_cli.py index cdba46b..1540ec4 100644 --- a/ruyi/device/provision_cli.py +++ b/ruyi/device/provision_cli.py @@ -5,6 +5,7 @@ from .. import log from ..cli import user_input +from ..cli.cmd import RootCommand from ..config import GlobalConfig from ..ruyipkg.atom import Atom from ..ruyipkg.host import get_native_host @@ -24,12 +25,27 @@ from ..utils import prereqs -def cli_device_provision(gc: GlobalConfig, args: argparse.Namespace) -> int: - try: - return do_provision_interactive(gc) - except KeyboardInterrupt: - log.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n") - return 1 +class DeviceCommand( + RootCommand, + cmd="device", + has_subcommands=True, + help="Manage devices", +): + pass + + +class DeviceProvisionCommand( + DeviceCommand, + cmd="provision", + help="Interactively initialize a device for development", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + try: + return do_provision_interactive(cfg) + except KeyboardInterrupt: + log.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n") + return 1 def do_provision_interactive(config: GlobalConfig) -> int: diff --git a/ruyi/mux/venv/venv_cli.py b/ruyi/mux/venv/venv_cli.py index 2854f70..329cd1d 100644 --- a/ruyi/mux/venv/venv_cli.py +++ b/ruyi/mux/venv/venv_cli.py @@ -4,6 +4,7 @@ from typing import Any from ... import log +from ...cli.cmd import RootCommand from ...config import GlobalConfig from ...ruyipkg.atom import Atom from ...ruyipkg.host import get_native_host @@ -12,6 +13,59 @@ from .provision import render_template_str, VenvMaker +class VenvCommand( + RootCommand, + cmd="venv", + help="Generate a virtual environment adapted to the chosen toolchain and profile", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument("profile", type=str, help="Profile to use for the environment") + p.add_argument("dest", type=str, help="Path to the new virtual environment") + p.add_argument( + "--name", + "-n", + type=str, + default=None, + help="Override the venv's name", + ) + p.add_argument( + "--toolchain", + "-t", + type=str, + action="append", + help="Specifier(s) (atoms) of the toolchain package(s) to use", + ) + p.add_argument( + "--emulator", + "-e", + type=str, + help="Specifier (atom) of the emulator package to use", + ) + p.add_argument( + "--with-sysroot", + action="store_true", + dest="with_sysroot", + default=True, + help="Provision a fresh sysroot inside the new virtual environment (default)", + ) + p.add_argument( + "--without-sysroot", + action="store_false", + dest="with_sysroot", + help="Do not include a sysroot inside the new virtual environment", + ) + p.add_argument( + "--sysroot-from", + type=str, + help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + return cli_venv(cfg, args) + + def cli_venv(config: GlobalConfig, args: argparse.Namespace) -> int: profile_name: str = args.profile dest = pathlib.Path(args.dest) diff --git a/ruyi/ruyipkg/admin_cli.py b/ruyi/ruyipkg/admin_cli.py index 942c69b..58f7145 100644 --- a/ruyi/ruyipkg/admin_cli.py +++ b/ruyi/ruyipkg/admin_cli.py @@ -9,36 +9,78 @@ from tomlkit import TOMLDocument, document, table from tomlkit.items import AoT, Table -from ..config import GlobalConfig from .. import log +from ..cli.cmd import RootCommand +from ..config import GlobalConfig from . import checksum from .canonical_dump import dump_canonical_package_manifest_toml from .pkg_manifest import DistfileDeclType, PackageManifest, RestrictKind -def cli_admin_manifest(cfg: GlobalConfig, args: argparse.Namespace) -> int: - files = args.file - format = args.format - restrict_str = cast(str, args.restrict) - restrict = restrict_str.split(",") if restrict_str else [] - - if not validate_restrict_kinds(restrict): - log.F(f"invalid restrict kinds given: {restrict}") - return 1 - - entries = [gen_distfile_entry(f, restrict) for f in files] - if format == "json": - sys.stdout.write(json.dumps(entries, indent=2)) - sys.stdout.write("\n") - return 0 - - if format == "toml": - doc = emit_toml_distfiles_section(entries) - log.D(f"{doc}") - sys.stdout.write(doc.as_string()) - return 0 - - raise RuntimeError("unrecognized output format; should never happen") +# Repo admin commands +class AdminCommand( + RootCommand, + cmd="admin", + has_subcommands=True, + # https://github.com/python/cpython/issues/67037 + # help=argparse.SUPPRESS, + help="(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos", +): + pass + + +class AdminManifestCommand( + AdminCommand, + cmd="manifest", + help="Generate manifest for the distfiles given", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--format", + "-f", + type=str, + choices=["json", "toml"], + default="json", + help="Format of manifest to generate", + ) + p.add_argument( + "--restrict", + type=str, + default="", + help="the 'restrict' field to use for all mentioned distfiles, separated with comma", + ) + p.add_argument( + "file", + type=str, + nargs="+", + help="Path to the distfile(s) to generate manifest for", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + files = args.file + format = args.format + restrict_str = cast(str, args.restrict) + restrict = restrict_str.split(",") if restrict_str else [] + + if not validate_restrict_kinds(restrict): + log.F(f"invalid restrict kinds given: {restrict}") + return 1 + + entries = [gen_distfile_entry(f, restrict) for f in files] + if format == "json": + sys.stdout.write(json.dumps(entries, indent=2)) + sys.stdout.write("\n") + return 0 + + if format == "toml": + doc = emit_toml_distfiles_section(entries) + log.D(f"{doc}") + sys.stdout.write(doc.as_string()) + return 0 + + raise RuntimeError("unrecognized output format; should never happen") RE_INDENT_FIX = re.compile(r"(?m)^ ([\"'{\[])") @@ -51,19 +93,34 @@ def _fix_indent(s: str) -> str: return RE_INDENT_FIX.sub(r" \1", s) -def cli_admin_format_manifest(cfg: GlobalConfig, args: argparse.Namespace) -> int: - files = args.file - - for f in files: - p = pathlib.Path(f) - pm = PackageManifest.load_from_path(p) - d = dump_canonical_package_manifest_toml(pm.to_raw()) +class AdminFormatManifestCommand( + AdminCommand, + cmd="format-manifest", + help="Format the given package manifests into canonical TOML representation", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "file", + type=str, + nargs="+", + help="Path to the distfile(s) to generate manifest for", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + files = args.file + + for f in files: + p = pathlib.Path(f) + pm = PackageManifest.load_from_path(p) + d = dump_canonical_package_manifest_toml(pm.to_raw()) + + dest_path = p.with_suffix(".toml") + with open(dest_path, "w", encoding="utf-8") as fp: + fp.write(_fix_indent(d.as_string())) - dest_path = p.with_suffix(".toml") - with open(dest_path, "w", encoding="utf-8") as fp: - fp.write(_fix_indent(d.as_string())) - - return 0 + return 0 def validate_restrict_kinds(input: list[str]) -> TypeGuard[list[RestrictKind]]: diff --git a/ruyi/ruyipkg/news_cli.py b/ruyi/ruyipkg/news_cli.py index fba767e..1ed31dd 100644 --- a/ruyi/ruyipkg/news_cli.py +++ b/ruyi/ruyipkg/news_cli.py @@ -3,10 +3,11 @@ from rich import box from rich.table import Table +from .. import is_porcelain, log +from ..cli.cmd import RootCommand from ..config import GlobalConfig from ..utils.markdown import RuyiStyledMarkdown from ..utils.porcelain import PorcelainOutput -from .. import is_porcelain, log from .news import NewsItem, NewsItemContent, NewsItemStore from .repo import MetadataRepo @@ -34,53 +35,98 @@ def print_news_item_titles( log.stdout(tbl) -def cli_news_list(config: GlobalConfig, args: argparse.Namespace) -> int: - only_unread = args.new +class NewsCommand( + RootCommand, + cmd="news", + has_subcommands=True, + help="List and read news items from configured repository", +): + pass + + +class NewsListCommand( + NewsCommand, + cmd="list", + help="List news items", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--new", + action="store_true", + help="List unread news items only", + ) - mr = MetadataRepo(config) - store = mr.news_store() - newsitems = store.list(only_unread) + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + only_unread = args.new - if is_porcelain(): - with PorcelainOutput() as po: - for ni in newsitems: - po.emit(ni.to_porcelain()) - return 0 + mr = MetadataRepo(cfg) + store = mr.news_store() + newsitems = store.list(only_unread) - log.stdout("[bold green]News items:[/bold green]\n") - if not newsitems: - log.stdout(" (no unread item)" if only_unread else " (no item)") - return 0 + if is_porcelain(): + with PorcelainOutput() as po: + for ni in newsitems: + po.emit(ni.to_porcelain()) + return 0 - print_news_item_titles(newsitems, config.lang_code) + log.stdout("[bold green]News items:[/bold green]\n") + if not newsitems: + log.stdout(" (no unread item)" if only_unread else " (no item)") + return 0 - return 0 + print_news_item_titles(newsitems, cfg.lang_code) + return 0 -def cli_news_read(config: GlobalConfig, args: argparse.Namespace) -> int: - quiet = args.quiet - items_strs = args.item - mr = MetadataRepo(config) - store = mr.news_store() +class NewsReadCommand( + NewsCommand, + cmd="read", + help="Read news items", + description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--quiet", + "-q", + action="store_true", + help="Do not output anything and only mark as read", + ) + p.add_argument( + "item", + type=str, + nargs="*", + help="Ordinal or ID of the news item(s) to read", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + quiet = args.quiet + items_strs = args.item - # filter out requested news items - items = filter_news_items_by_specs(store, items_strs) - if items is None: - return 1 + mr = MetadataRepo(cfg) + store = mr.news_store() - # render the items - if not quiet: - if items: - for ni in items: - print_news(ni.get_content_for_lang(config.lang_code)) - else: - log.stdout("No news to display.") + # filter out requested news items + items = filter_news_items_by_specs(store, items_strs) + if items is None: + return 1 - # record read statuses - store.mark_as_read(*(ni.id for ni in items)) + # render the items + if not quiet: + if items: + for ni in items: + print_news(ni.get_content_for_lang(cfg.lang_code)) + else: + log.stdout("No news to display.") - return 0 + # record read statuses + store.mark_as_read(*(ni.id for ni in items)) + + return 0 def filter_news_items_by_specs( diff --git a/ruyi/ruyipkg/pkg_cli.py b/ruyi/ruyipkg/pkg_cli.py index 3e84d7e..01fd4d2 100644 --- a/ruyi/ruyipkg/pkg_cli.py +++ b/ruyi/ruyipkg/pkg_cli.py @@ -7,10 +7,11 @@ import tempfile from typing import Iterable, Self, TypedDict -from ..utils.porcelain import PorcelainEntity, PorcelainEntityType, PorcelainOutput -from .host import canonicalize_host_str +from .host import canonicalize_host_str, get_native_host from .. import is_porcelain, log +from ..cli.cmd import RootCommand from ..config import GlobalConfig +from ..utils.porcelain import PorcelainEntity, PorcelainEntityType, PorcelainOutput from .atom import Atom from .distfile import Distfile from .repo import MetadataRepo @@ -18,26 +19,44 @@ from .unpack import ensure_unpack_cmd_for_method -def cli_list(config: GlobalConfig, args: argparse.Namespace) -> int: - verbose = args.verbose +class ListCommand( + RootCommand, + cmd="list", + has_subcommands=True, + is_subcommand_required=False, + has_main=True, + help="List available packages in configured repository", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "--verbose", + "-v", + action="store_true", + help="Also show details for every package", + ) + + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + verbose = args.verbose - mr = MetadataRepo(config) + mr = MetadataRepo(cfg) - augmented_pkgs = list(AugmentedPkg.yield_from_repo(mr)) + augmented_pkgs = list(AugmentedPkg.yield_from_repo(mr)) - if is_porcelain(): - return do_list_porcelain(augmented_pkgs) + if is_porcelain(): + return do_list_porcelain(augmented_pkgs) - if not verbose: - return do_list_non_verbose(augmented_pkgs) + if not verbose: + return do_list_non_verbose(augmented_pkgs) - for i, ver in enumerate(chain(*(ap.versions for ap in augmented_pkgs))): - if i > 0: - log.stdout("\n") + for i, ver in enumerate(chain(*(ap.versions for ap in augmented_pkgs))): + if i > 0: + log.stdout("\n") - print_pkg_detail(ver.pm) + print_pkg_detail(ver.pm) - return 0 + return 0 class PkgRemark(StrEnum): @@ -209,84 +228,138 @@ def is_root_likely_populated(root: str) -> bool: return False -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}") - - mr = MetadataRepo(config) - - for a_str in atom_strs: - a = Atom.parse(a_str) - pm = a.match_in_repo(mr, config.include_prereleases) - if pm is None: - log.F(f"atom {a_str} matches no package in the repository") - return 1 - pkg_name = pm.name_for_installation - - bm = pm.binary_metadata - sm = pm.source_metadata - if bm is None and sm is None: - log.F(f"don't know how to extract package [green]{pkg_name}[/green]") - return 2 - - if bm is not None and sm is not None: - log.F( - f"cannot handle package [green]{pkg_name}[/green]: package is both binary and source" - ) - return 2 - - distfiles_for_host: list[str] | None = None - if bm is not None: - distfiles_for_host = bm.get_distfile_names_for_host(host) - elif sm is not None: - distfiles_for_host = sm.get_distfile_names_for_host(host) - - if not distfiles_for_host: - log.F( - f"package [green]{pkg_name}[/green] declares no distfile for host {host}" - ) - return 2 - - dfs = pm.distfiles() +class ExtractCommand( + RootCommand, + cmd="extract", + help="Fetch package(s) then extract to current directory", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "atom", + type=str, + nargs="+", + help="Specifier (atom) of the package(s) to extract", + ) + p.add_argument( + "--host", + type=str, + default=get_native_host(), + help="Override the host architecture (normally not needed)", + ) - for df_name in distfiles_for_host: - df_decl = dfs[df_name] - urls = mr.get_distfile_urls(df_decl) - dest = os.path.join(config.ensure_distfiles_dir(), df_name) - ensure_unpack_cmd_for_method(df_decl.unpack_method) - df = Distfile(urls, dest, df_decl, mr) - df.ensure() + @classmethod + def main(cls, cfg: 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}") + + mr = MetadataRepo(cfg) + + for a_str in atom_strs: + a = Atom.parse(a_str) + pm = a.match_in_repo(mr, cfg.include_prereleases) + if pm is None: + log.F(f"atom {a_str} matches no package in the repository") + return 1 + pkg_name = pm.name_for_installation + + bm = pm.binary_metadata + sm = pm.source_metadata + if bm is None and sm is None: + log.F(f"don't know how to extract package [green]{pkg_name}[/green]") + return 2 + + if bm is not None and sm is not None: + log.F( + f"cannot handle package [green]{pkg_name}[/green]: package is both binary and source" + ) + return 2 + + distfiles_for_host: list[str] | None = None + if bm is not None: + distfiles_for_host = bm.get_distfile_names_for_host(host) + elif sm is not None: + distfiles_for_host = sm.get_distfile_names_for_host(host) + + if not distfiles_for_host: + log.F( + f"package [green]{pkg_name}[/green] declares no distfile for host {host}" + ) + return 2 + + dfs = pm.distfiles() + + for df_name in distfiles_for_host: + df_decl = dfs[df_name] + urls = mr.get_distfile_urls(df_decl) + dest = os.path.join(cfg.ensure_distfiles_dir(), df_name) + ensure_unpack_cmd_for_method(df_decl.unpack_method) + df = Distfile(urls, dest, df_decl, mr) + df.ensure() + + log.I( + f"extracting [green]{df_name}[/green] for package [green]{pkg_name}[/green]" + ) + # unpack into CWD + df.unpack(None) log.I( - f"extracting [green]{df_name}[/green] for package [green]{pkg_name}[/green]" + f"package [green]{pkg_name}[/green] extracted to current working directory" ) - # unpack into CWD - df.unpack(None) - log.I( - f"package [green]{pkg_name}[/green] extracted to current working directory" - ) + return 0 - return 0 +class InstallCommand( + RootCommand, + cmd="install", + aliases=["i"], + help="Install package from configured repository", +): + @classmethod + def configure_args(cls, p: argparse.ArgumentParser) -> None: + p.add_argument( + "atom", + type=str, + nargs="+", + help="Specifier (atom) of the package to install", + ) + p.add_argument( + "-f", + "--fetch-only", + action="store_true", + help="Fetch distribution files only without installing", + ) + p.add_argument( + "--host", + type=str, + default=get_native_host(), + help="Override the host architecture (normally not needed)", + ) + p.add_argument( + "--reinstall", + action="store_true", + help="Force re-installation of already installed packages", + ) -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 + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + host = args.host + atom_strs: set[str] = set(args.atom) + fetch_only = args.fetch_only + reinstall = args.reinstall - mr = MetadataRepo(config) + mr = MetadataRepo(cfg) - return do_install_atoms( - config, - mr, - atom_strs, - canonicalized_host=canonicalize_host_str(host), - fetch_only=fetch_only, - reinstall=reinstall, - ) + return do_install_atoms( + cfg, + mr, + atom_strs, + canonicalized_host=canonicalize_host_str(host), + fetch_only=fetch_only, + reinstall=reinstall, + ) def do_install_atoms( diff --git a/ruyi/ruyipkg/profile_cli.py b/ruyi/ruyipkg/profile_cli.py index c2c5b06..5515a41 100644 --- a/ruyi/ruyipkg/profile_cli.py +++ b/ruyi/ruyipkg/profile_cli.py @@ -2,18 +2,25 @@ from .. import log from ..config import GlobalConfig +from .pkg_cli import ListCommand from .repo import MetadataRepo -def cli_list_profiles(config: GlobalConfig, args: argparse.Namespace) -> int: - mr = MetadataRepo(config) +class ListProfilesCommand( + ListCommand, + cmd="profiles", + help="List all available profiles", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + mr = MetadataRepo(cfg) - for arch in mr.get_supported_arches(): - for p in mr.iter_profiles_for_arch(arch): - if not p.need_flavor: - log.stdout(p.id) - continue + for arch in mr.get_supported_arches(): + for p in mr.iter_profiles_for_arch(arch): + if not p.need_flavor: + log.stdout(p.id) + continue - log.stdout(f"{p.id} (needs flavor(s): {p.need_flavor})") + log.stdout(f"{p.id} (needs flavor(s): {p.need_flavor})") - return 0 + return 0 diff --git a/ruyi/ruyipkg/update_cli.py b/ruyi/ruyipkg/update_cli.py index 9002e9d..2436f87 100644 --- a/ruyi/ruyipkg/update_cli.py +++ b/ruyi/ruyipkg/update_cli.py @@ -1,20 +1,27 @@ import argparse from ..config import GlobalConfig +from ..cli.cmd import RootCommand from .. import log from . import news_cli from .repo import MetadataRepo -def cli_update(config: GlobalConfig, args: argparse.Namespace) -> int: - mr = MetadataRepo(config) - mr.sync() +class UpdateCommand( + RootCommand, + cmd="update", + help="Update RuyiSDK repo and packages", +): + @classmethod + def main(cls, cfg: GlobalConfig, args: argparse.Namespace) -> int: + mr = MetadataRepo(cfg) + mr.sync() - # check if there are new newsitems - unread_newsitems = mr.news_store().list(True) - if unread_newsitems: - log.stdout(f"\nThere are {len(unread_newsitems)} new news item(s):\n") - news_cli.print_news_item_titles(unread_newsitems, config.lang_code) - log.stdout("\nYou can read them with [yellow]ruyi news read[/yellow].") + # check if there are new newsitems + unread_newsitems = mr.news_store().list(True) + if unread_newsitems: + log.stdout(f"\nThere are {len(unread_newsitems)} new news item(s):\n") + news_cli.print_news_item_titles(unread_newsitems, cfg.lang_code) + log.stdout("\nYou can read them with [yellow]ruyi news read[/yellow].") - return 0 + return 0 From a3bef339609e0dbec6b291ac4a00308c316a89d9 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Sat, 2 Nov 2024 20:05:43 +0800 Subject: [PATCH 3/4] style: ruff format --- ruyi/cli/self_cli.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ruyi/cli/self_cli.py b/ruyi/cli/self_cli.py index c6cf783..3a3c511 100644 --- a/ruyi/cli/self_cli.py +++ b/ruyi/cli/self_cli.py @@ -100,7 +100,16 @@ def main(cls, cfg: config.GlobalConfig, args: argparse.Namespace) -> int: repo = True telemetry = True - if not any([distfiles, installed_pkgs, news_read_status, progcache, repo, telemetry]): + if not any( + [ + distfiles, + installed_pkgs, + news_read_status, + progcache, + repo, + telemetry, + ] + ): log.F("no data specified for cleaning") log.I( "please check [yellow]ruyi self clean --help[/] for a list of cleanable data" From aeddf1cf3784a66e78820af04d252d8bdb233d74 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Sat, 2 Nov 2024 20:07:11 +0800 Subject: [PATCH 4/4] refactor(cli): make _tele_key check robust --- ruyi/cli/cmd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ruyi/cli/cmd.py b/ruyi/cli/cmd.py index 00ad3f7..9e8898b 100644 --- a/ruyi/cli/cmd.py +++ b/ruyi/cli/cmd.py @@ -50,7 +50,8 @@ def __init_subclass__( if cmd is None: cls._tele_key = None else: - parent_raw_tele_key = cls.mro()[1]._tele_key + parent_cls = cls.mro()[1] + parent_raw_tele_key = getattr(parent_cls, "_tele_key", None) if parent_raw_tele_key is None: cls._tele_key = cmd else: