diff --git a/kiauh.sh b/kiauh.sh index 90a8399b..7a70bd7f 100755 --- a/kiauh.sh +++ b/kiauh.sh @@ -135,10 +135,12 @@ function main() { export PYTHONPATH="${entrypoint}" clear -x - python3 "${entrypoint}/kiauh/main.py" + python3 "${entrypoint}/kiauh/main.py" "$@" } -check_if_ratos -check_euid -kiauh_update_dialog -main +# skip update prompt when arguments are passed -> dont block cli runs +if [[ $# -eq 0 ]]; then + kiauh_update_dialog +fi + +main "$@" diff --git a/kiauh/core/cli/__init__.py b/kiauh/core/cli/__init__.py new file mode 100644 index 00000000..68ac9e37 --- /dev/null +++ b/kiauh/core/cli/__init__.py @@ -0,0 +1,365 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +import argparse +import sys +from typing import Callable, Dict, List, Tuple + +from components.klipper.services.klipper_setup_service import KlipperSetupService +from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService +from components.webui_client.services.web_client_config_setup_service import ( + WebClientConfigSetupService, +) +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) + +# A dispatcher receives the parsed argparse namespace and the parser (so it can +# raise ``parser.error`` for invalid input) and returns the CLI exit code. +Dispatcher = Callable[[argparse.Namespace, argparse.ArgumentParser], int] + + +def _add_klipper_install(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("klipper", help="Install Klipper") + p.add_argument( + "--count", type=int, default=None, help="Number of instances to install" + ) + p.add_argument( + "--name", action="append", default=[], help="Custom instance name(s)" + ) + p.add_argument( + "--create-example-cfg", action="store_true", help="Create example printer.cfg" + ) + p.add_argument( + "--match-moonraker", + action="store_true", + help="Match Klipper instance count to existing Moonraker instances", + ) + + +def _add_moonraker_install(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("moonraker", help="Install Moonraker") + p.add_argument( + "--klipper-suffix", + action="append", + default=[], + help="Klipper suffix to set up Moonraker for (can be repeated)", + ) + p.add_argument( + "--create-example-cfg", + action="store_true", + help="Create example moonraker.conf", + ) + + +def _add_web_client_install(sub: argparse._SubParsersAction) -> None: + for name in ("mainsail", "fluidd"): + p = sub.add_parser(name, help=f"Install {name.capitalize()}") + p.add_argument("--port", type=int, default=None, help="Listen port") + p.add_argument( + "--install-config", + action="store_true", + help="Install the recommended client config", + ) + p.add_argument( + "--continue-without-moonraker", + action="store_true", + help="Allow installation even if Moonraker is not installed", + ) + + sub.add_parser("mainsail-config", help="Install the Mainsail client config") + sub.add_parser("fluidd-config", help="Install the Fluidd client config") + + +def _add_klipper_remove(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("klipper", help="Remove Klipper") + p.add_argument("--service", action="store_true", help="Remove Klipper services") + p.add_argument("--dir", action="store_true", help="Remove Klipper local repository") + p.add_argument( + "--env", action="store_true", help="Remove Klipper Python environment" + ) + p.add_argument( + "--all", + action="store_true", + help="Remove every installed Klipper instance (destructive)", + ) + p.add_argument( + "--instance", + action="append", + default=[], + help="Klipper instance suffix to remove (repeatable)", + ) + + +def _add_moonraker_remove(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("moonraker", help="Remove Moonraker") + p.add_argument("--service", action="store_true", help="Remove Moonraker services") + p.add_argument( + "--dir", action="store_true", help="Remove Moonraker local repository" + ) + p.add_argument( + "--env", action="store_true", help="Remove Moonraker Python environment" + ) + p.add_argument( + "--polkit", action="store_true", help="Remove Moonraker policykit rules" + ) + p.add_argument( + "--all", + action="store_true", + help="Remove every installed Moonraker instance (destructive)", + ) + p.add_argument( + "--instance", + action="append", + default=[], + help="Moonraker instance suffix to remove (repeatable)", + ) + + +def _add_web_client_remove(sub: argparse._SubParsersAction) -> None: + for name in ("mainsail", "fluidd"): + p = sub.add_parser(name, help=f"Remove {name.capitalize()}") + p.add_argument("--client", action="store_true", help="Remove the web client") + p.add_argument("--config", action="store_true", help="Remove the client config") + p.add_argument("--no-backup", action="store_true", help="Skip config backup") + + +def _add_klipper_update(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("klipper", help="Update Klipper") + p.add_argument("--backup", action="store_true", help="Backup before updating") + + +def _add_moonraker_update(sub: argparse._SubParsersAction) -> None: + sub.add_parser("moonraker", help="Update Moonraker") + + +def _add_web_client_update(sub: argparse._SubParsersAction) -> None: + for name in ("mainsail", "fluidd"): + sub.add_parser(name, help=f"Update {name.capitalize()}") + sub.add_parser("mainsail-config", help="Update the Mainsail client config") + sub.add_parser("fluidd-config", help="Update the Fluidd client config") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="kiauh") + subparsers = parser.add_subparsers(dest="command") + + install = subparsers.add_parser("install", help="Install a component") + install_sub = install.add_subparsers(dest="component", required=True) + _add_klipper_install(install_sub) + _add_moonraker_install(install_sub) + _add_web_client_install(install_sub) + + remove = subparsers.add_parser("remove", help="Remove a component") + remove_sub = remove.add_subparsers(dest="component", required=True) + _add_klipper_remove(remove_sub) + _add_moonraker_remove(remove_sub) + _add_web_client_remove(remove_sub) + + update = subparsers.add_parser("update", help="Update a component") + update_sub = update.add_subparsers(dest="component", required=True) + _add_klipper_update(update_sub) + _add_moonraker_update(update_sub) + _add_web_client_update(update_sub) + + return parser + + +# --------------------------------------------------------------------------- # +# Command handlers: one callable per (command, component) pair. # +# Adding a new component is a matter of registering a handler here instead of # +# extending the previous long if/elif chain. # +# --------------------------------------------------------------------------- # +def _install_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + if args.count is not None and args.name and args.count != len(args.name): + parser.error("--count must match the number of --name values") + + service = KlipperSetupService() + custom_names = {i: name for i, name in enumerate(args.name)} if args.name else None + result = service.install( + count=args.count, + custom_names=custom_names, + create_example_cfg=args.create_example_cfg, + match_moonraker=args.match_moonraker, + interactive=False, + ) + return 0 if result else 1 + + +def _remove_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + if not (args.service or args.dir or args.env): + parser.error( + "specify at least one of --service, --dir, --env for 'remove klipper'" + ) + if args.service and not (args.all or args.instance): + # refuse to silently wipe every Klipper instance. + parser.error( + "removing Klipper services is destructive; pass --all or " + "--instance (repeatable) to select what to remove" + ) + service = KlipperSetupService() + result = service.remove( + remove_service=args.service, + remove_dir=args.dir, + remove_env=args.env, + remove_all=args.all, + instance_suffixes=args.instance or None, + interactive=False, + ) + return 0 if result else 1 + + +def _update_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + service = KlipperSetupService() + if args.backup: + service.settings.kiauh.backup_before_update = True + result = service.update(interactive=False) + return 0 if result else 1 + + +def _install_moonraker( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + service = MoonrakerSetupService() + result = service.install( + klipper_suffixes=args.klipper_suffix or None, + create_example_cfg=args.create_example_cfg, + interactive=False, + ) + return 0 if result else 1 + + +def _remove_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + if not (args.service or args.dir or args.env or args.polkit): + parser.error( + "specify at least one of --service, --dir, --env, --polkit " + "for 'remove moonraker'" + ) + if args.service and not (args.all or args.instance): + # refuse to silently wipe every Moonraker instance. + parser.error( + "removing Moonraker services is destructive; pass --all or " + "--instance (repeatable) to select what to remove" + ) + service = MoonrakerSetupService() + result = service.remove( + remove_service=args.service, + remove_dir=args.dir, + remove_env=args.env, + remove_polkit=args.polkit, + remove_all=args.all, + instance_suffixes=args.instance or None, + interactive=False, + ) + return 0 if result else 1 + + +def _update_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + service = MoonrakerSetupService() + result = service.update(interactive=False) + return 0 if result else 1 + + +def _install_web_client( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + service = WebClientSetupService(args.component) + result = service.install( + port=args.port, + install_client_cfg=args.install_config, + continue_without_moonraker=args.continue_without_moonraker, + interactive=False, + ) + return 0 if result else 1 + + +def _install_web_client_config( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + client_name = args.component.replace("-config", "") + result = WebClientConfigSetupService(client_name).install(interactive=False) + return 0 if result else 1 + + +def _remove_web_client( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + if not (args.client or args.config): + parser.error( + f"specify at least one of --client, --config for 'remove {args.component}'" + ) + service = WebClientSetupService(args.component) + result = service.remove( + remove_client=args.client, + remove_client_cfg=args.config, + backup_config=not args.no_backup, + interactive=False, + ) + return 0 if result else 1 + + +def _update_web_client( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + result = WebClientSetupService(args.component).update() + return 0 if result else 1 + + +def _update_web_client_config( + args: argparse.Namespace, parser: argparse.ArgumentParser +) -> int: + client_name = args.component.replace("-config", "") + result = WebClientConfigSetupService(client_name).update(interactive=False) + return 0 if result else 1 + + +# Dispatch registry: (command, component) -> handler. Keeping this as a module +# constant (not a closure) keeps ``run_cli`` trivial and lets tests assert which +# combinations are actually supported. +DISPATCH: Dict[Tuple[str, str], Dispatcher] = { + ("install", "klipper"): _install_klipper, + ("remove", "klipper"): _remove_klipper, + ("update", "klipper"): _update_klipper, + ("install", "moonraker"): _install_moonraker, + ("remove", "moonraker"): _remove_moonraker, + ("update", "moonraker"): _update_moonraker, + ("install", "mainsail"): _install_web_client, + ("install", "fluidd"): _install_web_client, + ("remove", "mainsail"): _remove_web_client, + ("remove", "fluidd"): _remove_web_client, + ("update", "mainsail"): _update_web_client, + ("update", "fluidd"): _update_web_client, + ("install", "mainsail-config"): _install_web_client_config, + ("install", "fluidd-config"): _install_web_client_config, + ("update", "mainsail-config"): _update_web_client_config, + ("update", "fluidd-config"): _update_web_client_config, +} + + +def run_cli(argv: List[str] | None = None) -> int: + """Run a headless CLI command. + + Returns 0 on success, -1 if no command was provided (-> fall back to TUI), + and a positive exit code when a command reports failure. + """ + parser = build_parser() + args = parser.parse_args(argv) + + if not args.command: + return -1 + + handler = DISPATCH.get((args.command, args.component)) + if handler is None: + parser.error(f"Unsupported command: {args.command} {args.component}") + return handler(args, parser) + + +def main() -> None: + sys.exit(run_cli()) diff --git a/kiauh/core/cli/tests/__init__.py b/kiauh/core/cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kiauh/core/cli/tests/test_cli.py b/kiauh/core/cli/tests/test_cli.py new file mode 100644 index 00000000..2bb6c7d6 --- /dev/null +++ b/kiauh/core/cli/tests/test_cli.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Set + +import core.cli as cli_module +import pytest +from core.cli import run_cli + + +class FakeKlipperService: + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + self.results: Dict[str, bool] = {} + self.settings = type( + "Settings", + (), + { + "kiauh": type( + "KiauhSettingsSection", (), {"backup_before_update": False} + )() + }, + )() + + def install(self, **kwargs: Any) -> bool: + self.calls.append({"method": "install", "kwargs": kwargs}) + return self.results.get("install", True) + + def remove(self, **kwargs: Any) -> bool: + self.calls.append({"method": "remove", "kwargs": kwargs}) + return self.results.get("remove", True) + + def update(self, **kwargs: Any) -> bool: + self.calls.append({"method": "update", "kwargs": kwargs}) + return self.results.get("update", True) + + +class FakeMoonrakerService: + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + self.results: Dict[str, bool] = {} + + def install(self, **kwargs: Any) -> bool: + self.calls.append({"method": "install", "kwargs": kwargs}) + return self.results.get("install", True) + + def remove(self, **kwargs: Any) -> bool: + self.calls.append({"method": "remove", "kwargs": kwargs}) + return self.results.get("remove", True) + + def update(self, **kwargs: Any) -> bool: + self.calls.append({"method": "update", "kwargs": kwargs}) + return self.results.get("update", True) + + +class FakeWebClientService: + def __init__(self) -> None: + self.install_calls: List[Dict[str, Any]] = [] + self.remove_calls: List[Dict[str, Any]] = [] + self.update_calls: List[str] = [] + self.results: Dict[str, bool] = {} + + def install(self, **kwargs: Any) -> bool: + self.install_calls.append(kwargs) + return self.results.get("install", True) + + def remove(self, **kwargs: Any) -> bool: + self.remove_calls.append(kwargs) + return self.results.get("remove", True) + + def update(self) -> bool: + self.update_calls.append("update") + return self.results.get("update", True) + + +class FakeWebClientConfigService: + def __init__(self) -> None: + self.install_calls: List[Dict[str, Any]] = [] + self.update_calls: List[Dict[str, Any]] = [] + self.results: Dict[str, bool] = {} + + def install(self, **kwargs: Any) -> bool: + self.install_calls.append(kwargs) + return self.results.get("install", True) + + def update(self, **kwargs: Any) -> bool: + self.update_calls.append(kwargs) + return self.results.get("update", True) + + +@pytest.fixture +def fake_service(monkeypatch: pytest.MonkeyPatch) -> FakeKlipperService: + fake = FakeKlipperService() + monkeypatch.setattr("core.cli.KlipperSetupService", lambda: fake) + return fake + + +@pytest.fixture +def fake_moonraker_service(monkeypatch: pytest.MonkeyPatch) -> FakeMoonrakerService: + fake = FakeMoonrakerService() + monkeypatch.setattr("core.cli.MoonrakerSetupService", lambda: fake) + return fake + + +@pytest.fixture +def fake_web_client_service(monkeypatch: pytest.MonkeyPatch) -> FakeWebClientService: + fake = FakeWebClientService() + monkeypatch.setattr("core.cli.WebClientSetupService", lambda name: fake) + return fake + + +@pytest.fixture +def fake_web_client_config_service( + monkeypatch: pytest.MonkeyPatch, +) -> FakeWebClientConfigService: + fake = FakeWebClientConfigService() + monkeypatch.setattr("core.cli.WebClientConfigSetupService", lambda name: fake) + return fake + + +class TestCliDispatch: + def test_no_args_returns_tui_signal(self) -> None: + assert run_cli([]) == -1 + + def test_install_klipper(self, fake_service: FakeKlipperService) -> None: + rc = run_cli(["install", "klipper", "--count", "2"]) + assert rc == 0 + assert fake_service.calls == [ + { + "method": "install", + "kwargs": { + "count": 2, + "custom_names": None, + "create_example_cfg": False, + "match_moonraker": False, + "interactive": False, + }, + } + ] + + def test_install_klipper_default_count_is_none( + self, fake_service: FakeKlipperService + ) -> None: + rc = run_cli(["install", "klipper"]) + assert rc == 0 + assert fake_service.calls[0]["kwargs"]["count"] is None + + def test_install_klipper_with_names(self, fake_service: FakeKlipperService) -> None: + rc = run_cli(["install", "klipper", "--name", "a", "--name", "b"]) + assert rc == 0 + assert fake_service.calls[0]["kwargs"]["custom_names"] == {0: "a", 1: "b"} + assert fake_service.calls[0]["kwargs"]["count"] is None + + def test_install_klipper_count_and_name_mismatch_rejected(self) -> None: + with pytest.raises(SystemExit): + run_cli([ + "install", + "klipper", + "--count", + "3", + "--name", + "a", + "--name", + "b", + ]) + + def test_install_klipper_with_flags(self, fake_service: FakeKlipperService) -> None: + rc = run_cli([ + "install", + "klipper", + "--create-example-cfg", + "--match-moonraker", + ]) + assert rc == 0 + kwargs = fake_service.calls[0]["kwargs"] + assert kwargs["create_example_cfg"] is True + assert kwargs["match_moonraker"] is True + assert kwargs["interactive"] is False + + def test_install_klipper_failure_returns_nonzero( + self, fake_service: FakeKlipperService + ) -> None: + fake_service.results["install"] = False + assert run_cli(["install", "klipper"]) == 1 + + def test_remove_klipper(self, fake_service: FakeKlipperService) -> None: + rc = run_cli(["remove", "klipper", "--service", "--all", "--dir", "--env"]) + assert rc == 0 + assert fake_service.calls == [ + { + "method": "remove", + "kwargs": { + "remove_service": True, + "interactive": False, + "remove_dir": True, + "remove_env": True, + "remove_all": True, + "instance_suffixes": None, + }, + } + ] + + def test_remove_klipper_failure_returns_nonzero( + self, fake_service: FakeKlipperService + ) -> None: + fake_service.results["remove"] = False + assert run_cli(["remove", "klipper", "--service", "--all"]) == 1 + + def test_remove_klipper_no_flags_is_rejected( + self, fake_service: FakeKlipperService + ) -> None: + # a remove with no removal flags must not silently succeed + with pytest.raises(SystemExit): + run_cli(["remove", "klipper"]) + assert fake_service.calls == [] + + def test_remove_klipper_service_without_explicit_intent_is_rejected( + self, fake_service: FakeKlipperService + ) -> None: + # `--service` alone must NOT silently wipe all instances. + # The user must pass `--all` (or `--instance `). + with pytest.raises(SystemExit): + run_cli(["remove", "klipper", "--service"]) + assert fake_service.calls == [] + + def test_remove_klipper_with_instance_suffix( + self, fake_service: FakeKlipperService + ) -> None: + rc = run_cli([ + "remove", + "klipper", + "--service", + "--instance", + "a", + "--instance", + "b", + ]) + assert rc == 0 + assert fake_service.calls[0]["kwargs"]["instance_suffixes"] == ["a", "b"] + assert fake_service.calls[0]["kwargs"]["remove_all"] is False + + def test_update_klipper(self, fake_service: FakeKlipperService) -> None: + rc = run_cli(["update", "klipper"]) + assert rc == 0 + assert fake_service.calls == [ + {"method": "update", "kwargs": {"interactive": False}} + ] + + def test_update_klipper_with_backup_flag( + self, fake_service: FakeKlipperService + ) -> None: + rc = run_cli(["update", "klipper", "--backup"]) + assert rc == 0 + assert fake_service.settings.kiauh.backup_before_update is True + + def test_update_klipper_failure_returns_nonzero( + self, fake_service: FakeKlipperService + ) -> None: + fake_service.results["update"] = False + assert run_cli(["update", "klipper"]) == 1 + + +class TestMoonrakerCliDispatch: + def test_install_moonraker_default( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + rc = run_cli(["install", "moonraker"]) + assert rc == 0 + assert fake_moonraker_service.calls == [ + { + "method": "install", + "kwargs": { + "klipper_suffixes": None, + "create_example_cfg": False, + "interactive": False, + }, + } + ] + + def test_install_moonraker_with_suffixes( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + rc = run_cli([ + "install", + "moonraker", + "--klipper-suffix", + "a", + "--klipper-suffix", + "b", + ]) + assert rc == 0 + assert fake_moonraker_service.calls[0]["kwargs"]["klipper_suffixes"] == [ + "a", + "b", + ] + + def test_install_moonraker_failure_returns_nonzero( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + fake_moonraker_service.results["install"] = False + assert run_cli(["install", "moonraker"]) == 1 + + def test_remove_moonraker( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + rc = run_cli([ + "remove", + "moonraker", + "--service", + "--all", + "--dir", + "--env", + "--polkit", + ]) + assert rc == 0 + assert fake_moonraker_service.calls == [ + { + "method": "remove", + "kwargs": { + "remove_service": True, + "remove_dir": True, + "remove_env": True, + "remove_polkit": True, + "interactive": False, + "remove_all": True, + "instance_suffixes": None, + }, + } + ] + + def test_remove_moonraker_service_without_explicit_intent_is_rejected( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + # `--service` alone must NOT silently wipe all instances. + with pytest.raises(SystemExit): + run_cli(["remove", "moonraker", "--service"]) + assert fake_moonraker_service.calls == [] + + def test_update_moonraker( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + rc = run_cli(["update", "moonraker"]) + assert rc == 0 + assert fake_moonraker_service.calls == [ + {"method": "update", "kwargs": {"interactive": False}} + ] + + def test_remove_moonraker_no_flags_is_rejected( + self, fake_moonraker_service: FakeMoonrakerService + ) -> None: + # a remove with no removal flags must not silently succeed + with pytest.raises(SystemExit): + run_cli(["remove", "moonraker"]) + assert fake_moonraker_service.calls == [] + + +class TestWebClientCliDispatch: + def test_install_mainsail( + self, fake_web_client_service: FakeWebClientService + ) -> None: + rc = run_cli([ + "install", + "mainsail", + "--port", + "8080", + "--install-config", + "--continue-without-moonraker", + ]) + assert rc == 0 + assert fake_web_client_service.install_calls == [ + { + "port": 8080, + "install_client_cfg": True, + "continue_without_moonraker": True, + "interactive": False, + } + ] + + def test_install_fluidd_default( + self, fake_web_client_service: FakeWebClientService + ) -> None: + rc = run_cli(["install", "fluidd"]) + assert rc == 0 + assert fake_web_client_service.install_calls == [ + { + "port": None, + "install_client_cfg": False, + "continue_without_moonraker": False, + "interactive": False, + } + ] + + def test_install_client_config_runs_non_interactively( + self, fake_web_client_config_service: FakeWebClientConfigService + ) -> None: + rc = run_cli(["install", "mainsail-config"]) + assert rc == 0 + assert fake_web_client_config_service.install_calls == [{"interactive": False}] + + def test_install_web_client_failure_returns_nonzero( + self, fake_web_client_service: FakeWebClientService + ) -> None: + fake_web_client_service.results["install"] = False + assert run_cli(["install", "mainsail"]) == 1 + + def test_remove_mainsail_no_flags_is_rejected( + self, fake_web_client_service: FakeWebClientService + ) -> None: + # a remove with no removal flags must not silently succeed + with pytest.raises(SystemExit): + run_cli(["remove", "mainsail"]) + assert fake_web_client_service.remove_calls == [] + + def test_remove_mainsail_with_client_and_config( + self, fake_web_client_service: FakeWebClientService + ) -> None: + rc = run_cli(["remove", "mainsail", "--client", "--config"]) + assert rc == 0 + assert fake_web_client_service.remove_calls == [ + { + "remove_client": True, + "remove_client_cfg": True, + "backup_config": True, + "interactive": False, + } + ] + + def test_remove_fluidd_no_backup( + self, fake_web_client_service: FakeWebClientService + ) -> None: + rc = run_cli(["remove", "fluidd", "--client", "--no-backup"]) + assert rc == 0 + assert fake_web_client_service.remove_calls == [ + { + "remove_client": True, + "remove_client_cfg": False, + "backup_config": False, + "interactive": False, + } + ] + + def test_update_mainsail( + self, fake_web_client_service: FakeWebClientService + ) -> None: + rc = run_cli(["update", "mainsail"]) + assert rc == 0 + assert fake_web_client_service.update_calls == ["update"] + + def test_update_fluidd_config_runs_non_interactively( + self, fake_web_client_config_service: FakeWebClientConfigService + ) -> None: + rc = run_cli(["update", "fluidd-config"]) + assert rc == 0 + assert fake_web_client_config_service.update_calls == [{"interactive": False}] + + +class TestDispatchRegistry: + """``run_cli`` must use a dispatch registry instead of a long + if/elif chain, and the registry must cover every (command, component) pair + the argument parser can produce.""" + + _EXPECTED: Set[tuple] = { + ("install", "klipper"), + ("remove", "klipper"), + ("update", "klipper"), + ("install", "moonraker"), + ("remove", "moonraker"), + ("update", "moonraker"), + ("install", "mainsail"), + ("install", "fluidd"), + ("remove", "mainsail"), + ("remove", "fluidd"), + ("update", "mainsail"), + ("update", "fluidd"), + ("install", "mainsail-config"), + ("install", "fluidd-config"), + ("update", "mainsail-config"), + ("update", "fluidd-config"), + } + + def test_dispatch_registry_exists_and_covers_every_pair(self) -> None: + dispatch = getattr(cli_module, "DISPATCH", None) + assert dispatch is not None, "run_cli must expose a DISPATCH registry" + assert set(dispatch.keys()) == self._EXPECTED + for handler in dispatch.values(): + assert callable(handler) + + def test_subparser_helpers_are_typed_not_any(self) -> None: + # the ``_add_*`` helpers must accept ``argparse._SubParsersAction``, not ``Any``. + import inspect + + for name in dir(cli_module): + if not name.startswith("_add_"): + continue + func = getattr(cli_module, name) + if not inspect.isfunction(func): + continue + hints = inspect.signature(func).parameters.get("sub") + assert hints is not None + assert hints.annotation is not Any, f"{name} must not type ``sub`` as Any" + assert "SubParsersAction" in str(hints.annotation), ( + f"{name} must type ``sub`` as an argparse SubParsersAction" + ) + + +class TestPackaging: + def test_pyproject_metadata_allows_editable_dev_install(self) -> None: + project_root = Path(__file__).resolve().parents[4] + import subprocess as sp + + result = sp.run( + ["python", "-m", "pip", "install", "--dry-run", "-e", ".[dev]"], + cwd=project_root, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr diff --git a/kiauh/main.py b/kiauh/main.py index 3832ba69..e457778f 100644 --- a/kiauh/main.py +++ b/kiauh/main.py @@ -9,6 +9,7 @@ import io import sys +from core.cli import run_cli from core.logger import Logger from core.menus.main_menu import MainMenu from core.settings.kiauh_settings import KiauhSettings @@ -21,12 +22,16 @@ def ensure_encoding() -> None: def main() -> None: - try: - KiauhSettings() - ensure_encoding() - MainMenu().run() - except KeyboardInterrupt: - Logger.print_ok("\nHappy printing!\n", prefix=False) + rc = run_cli() + if rc == -1: + try: + KiauhSettings() + ensure_encoding() + MainMenu().run() + except KeyboardInterrupt: + Logger.print_ok("\nHappy printing!\n", prefix=False) + elif rc > 0: + sys.exit(rc) if __name__ == "__main__":