feat(cli): add headless command-line interface

Add core.cli with argparse-based install/remove/update commands for Klipper,

Moonraker, Mainsail/Fluidd and client configs.

Wire main.py to dispatch to the CLI and fall back to the TUI when no args

are given. Pass CLI arguments through kiauh.sh and skip the update dialog

for non-interactive runs.
This commit is contained in:
dw-0
2026-07-11 17:49:51 +02:00
parent adf3e292fb
commit 9b93450d98
5 changed files with 900 additions and 11 deletions
+7 -5
View File
@@ -135,10 +135,12 @@ function main() {
export PYTHONPATH="${entrypoint}" export PYTHONPATH="${entrypoint}"
clear -x clear -x
python3 "${entrypoint}/kiauh/main.py" python3 "${entrypoint}/kiauh/main.py" "$@"
} }
check_if_ratos # skip update prompt when arguments are passed -> dont block cli runs
check_euid if [[ $# -eq 0 ]]; then
kiauh_update_dialog kiauh_update_dialog
main fi
main "$@"
+365
View File
@@ -0,0 +1,365 @@
# ======================================================================= #
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 <suffix> (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 <suffix> (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())
View File
+517
View File
@@ -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 <suffix>`).
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
+5
View File
@@ -9,6 +9,7 @@
import io import io
import sys import sys
from core.cli import run_cli
from core.logger import Logger from core.logger import Logger
from core.menus.main_menu import MainMenu from core.menus.main_menu import MainMenu
from core.settings.kiauh_settings import KiauhSettings from core.settings.kiauh_settings import KiauhSettings
@@ -21,12 +22,16 @@ def ensure_encoding() -> None:
def main() -> None: def main() -> None:
rc = run_cli()
if rc == -1:
try: try:
KiauhSettings() KiauhSettings()
ensure_encoding() ensure_encoding()
MainMenu().run() MainMenu().run()
except KeyboardInterrupt: except KeyboardInterrupt:
Logger.print_ok("\nHappy printing!\n", prefix=False) Logger.print_ok("\nHappy printing!\n", prefix=False)
elif rc > 0:
sys.exit(rc)
if __name__ == "__main__": if __name__ == "__main__":