Compare commits

..
109 changed files with 1916 additions and 6943 deletions
-36
View File
@@ -1,36 +0,0 @@
name: Tests
on:
push:
branches: [develop, master]
pull_request:
branches: [develop, master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests with coverage
run: pytest --cov=kiauh --cov-report=xml --cov-report=term
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.python-version }}
path: coverage.xml
-4
View File
@@ -1,8 +1,6 @@
.idea
.vscode
.pytest_cache
.ruff_cache
.mypy_cache
.jupyter
*.ipynb
*.ipynb_checkpoints
@@ -12,7 +10,5 @@ __pycache__
.venv
*.code-workspace
*.iml
*.egg-info
.coverage
kiauh.cfg
klipper_repos.txt
+82
View File
@@ -0,0 +1,82 @@
# Live System Testing
Live tests run KIAUH workflows against a real Debian 12 QEMU/KVM VM. They are
isolated from the local developer machine by design.
## Safety Rules
- Live tests NEVER run on the local machine.
- They require `KIAUH_LIVE_ALLOW=1`.
- The target host must be explicitly set via `KIAUH_LIVE_TARGET_HOST` and match
the VM in the inventory.
- Local hostnames, loopback addresses, and the current hostname are blocked.
## Prepare a VM
1. Create a Debian 12 QEMU/KVM VM.
2. Create a user with passwordless sudo.
3. Install an SSH key for that user.
4. Install KIAUH on the VM (e.g. clone this repository).
5. Create a clean snapshot named `clean`:
```bash
virsh snapshot-create-as debian12-kiauh clean
```
## Inventory
`kiauh/live/inventory.yaml` defines non-sensitive VM settings. The host and SSH
key path are read from environment variables or a `.env` file so they are not
committed.
Create `.env` in the project root:
```bash
KIAUH_LIVE_DEBIAN12_KIAUH_HOST=192.168.122.10
KIAUH_LIVE_DEBIAN12_KIAUH_KEY_FILE=/home/you/.ssh/kiauh_vm
```
Variable naming: `KIAUH_LIVE_<VM_NAME>_HOST` and `KIAUH_LIVE_<VM_NAME>_KEY_FILE`,
with the VM name uppercased and hyphens replaced by underscores.
You can also point to a custom inventory file:
```bash
export KIAUH_LIVE_INVENTORY=/path/to/inventory.yaml
```
## Run Live Tests
```bash
export KIAUH_LIVE_ALLOW=1
export KIAUH_LIVE_TARGET_HOST=192.168.122.10
pytest -m live
```
Each scenario reverts the VM to the clean snapshot first, so scenarios are
independent.
## Add a Scenario
Create a YAML file in `kiauh/live/scenarios/`:
```yaml
name: Install Klipper on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
expected:
- type: service
name: klipper.service
state: running
```
Supported assertion types: `service`, `file`, `package`, `port`, `command`.
## Troubleshooting
- `UnsafeTargetError`: check `KIAUH_LIVE_ALLOW` and `KIAUH_LIVE_TARGET_HOST`.
- `InventoryError`: check the inventory YAML path and format.
- `LiveRunnerError` during snapshot revert: ensure `virsh` works and the domain
and snapshot names match the inventory.
+74
View File
@@ -0,0 +1,74 @@
## ⚠️ Working on this PRD
Do NOT implement this PRD directly. It has been broken into sequential tasks.
Work through the tasks below in order.
## Task Index
| # | Task | Todo | Blocked by | Status |
|---|------|------|------------|--------|
| 1/5 | VM inventory, SSH fixture, and safety guards | TODO-006 | — | 🔄 open |
| 2/5 | Scenario loader and Klipper install scenario | TODO-007 | TODO-006 | ⏳ blocked |
| 3/5 | Remove Klipper and Moonraker scenarios | TODO-008 | TODO-007 | ⏳ blocked |
| 4/5 | Mainsail and Fluidd install scenarios | TODO-009 | TODO-008 | ⏳ blocked |
| 5/5 | Backup, restore, and update scenarios | TODO-010 | TODO-009 | ⏳ blocked |
Start with: **TODO-006** (PRD #2 - Task 1/5: VM inventory, SSH fixture, and safety guards)
---
# PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM
**Tags:** `prd`, `prd-2`
## Problem Statement
- Pytest unit tests cannot validate real package installs, systemd services, git clones, and OS-specific behavior.
- Running workflow tests on a local developer machine risks destroying the environment.
- Need reproducible, isolated acceptance tests with explicit expected outcomes.
## Solution
- Acceptance tests run only on a pre-built Debian 12 QEMU/KVM VM.
- Test harness connects via SSH; never executes on the local host.
- YAML scenarios define workflows and expected outcomes.
- VM snapshot reverted before every scenario.
- Multi-layer safety prevents local execution.
## User Stories
1. As a maintainer, I want install/remove Klipper workflow tested on a real VM, so I know the installer still works.
2. As a maintainer, I want install/remove Moonraker workflow tested, so API stack compatibility is verified.
3. As a maintainer, I want Mainsail/Fluidd install workflow tested, so web client setup works end-to-end.
4. As a maintainer, I want backup/restore workflow tested, so user data survives the cycle.
5. As a maintainer, I want tests parameterized by VM inventory, so future Ubuntu/Debian versions can be added without code changes.
6. As a maintainer, I want local execution blocked by multiple guards, so the developer machine is never modified.
7. As a CI operator, I want scenario results to show expected vs actual outcome, so failures are actionable.
## Implementation Decisions
- VM inventory config supplies host/IP, SSH user/key, OS family. No auto-provisioning; base images are prepared in advance.
- Test harness: pytest + SSH fixture + Testinfra assertions. Commands are routed through SSH; assertions use Testinfra modules for service/file/package/port state.
- Scenario schema YAML: `name`, `os`, `steps` (commands/options), `expected` (assertions for service running, file exists, package installed, port reachable, process present).
- Snapshot reset: revert VM overlay before every scenario. Scenarios must be independent.
- Safety guards: require `KIAUH_LIVE_TARGET_HOST`; abort if value is `localhost`, `127.*`, or matches current hostname; abort if target resolves to a local interface; verify SSH host key differs from local; optional explicit confirmation prompt.
- Workflow priority: (1) install/remove Klipper; (2) install/remove Moonraker; (3) install Mainsail/Fluidd; (4) backup/restore; (5) update flows.
- Test user on VM has passwordless sudo; VM has internet access; long installs use timeouts.
- Scenario runner exposes expected outcome per step; failure shows command, expected assertion, and actual result.
## Testing Decisions
- Acceptance tests verify observable system state, not internal functions.
- Each scenario defines exact pre-state (clean snapshot) and post-state assertions.
- Flaky network commands are retried with timeout; failures attach relevant VM logs.
- Live suite is marked with a `live` pytest marker and excluded from the default `pytest` run.
## Out of Scope
- Running live tests on the local machine or bare metal.
- Auto-provisioning or building VM images.
- Testing every extension in the first iteration; only core workflows.
## Further Notes
- Future OS matrix (Ubuntu 22.04/24.04) is enabled by adding inventory entries and matching base images; no harness changes needed.
+31
View File
@@ -0,0 +1,31 @@
# PRD #2 - Task 1/5: VM inventory, SSH fixture, and safety guards
**Tags:** `task`, `prd-2`
## Parent PRD
PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM (`docs/prd/PRD-002-live-vm-test-strategy.md`)
## What to build
Create the VM inventory config schema (host/IP, SSH user/key, OS family). Implement the SSH connection fixture and the multi-layer safety guards that abort if the target could be the local machine. Write tests for the guards without executing any workflow.
## Acceptance criteria
- [ ] Inventory config schema documented and validated.
- [ ] SSH fixture connects only when target is explicitly allowed.
- [ ] Guards block `localhost`, `127.*`, current hostname, local interfaces, and unknown SSH host keys.
- [ ] Guard tests run on the local machine and prove the blocks work.
## Blocked by
None — can start immediately.
## Next task
- TODO-007 (PRD #2 - Task 2/5: Scenario loader and Klipper install scenario)
## User stories addressed
- User story 5
- User story 6
+32
View File
@@ -0,0 +1,32 @@
# PRD #2 - Task 2/5: Scenario loader and Klipper install scenario
**Tags:** `task`, `prd-2`
## Parent PRD
PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM (`docs/prd/PRD-002-live-vm-test-strategy.md`)
## What to build
Implement the YAML scenario loader and the `live` pytest marker. Write the first end-to-end scenario: install Klipper on the Debian 12 VM, define expected outcomes (service file, env file, folders), and run it with snapshot revert before the scenario.
## Acceptance criteria
- [ ] YAML scenario loader parses `name`, `os`, `steps`, and `expected` assertions.
- [ ] `pytest -m live` runs only live scenarios; default run skips them.
- [ ] Klipper install scenario runs on the VM and passes.
- [ ] Snapshot revert happens before the scenario.
- [ ] Expected outcomes include file, service, and folder assertions.
## Blocked by
- TODO-006 (PRD #2 - Task 1/5: VM inventory, SSH fixture, and safety guards)
## Next task
- TODO-008 (PRD #2 - Task 3/5: Remove Klipper and Moonraker scenarios)
## User stories addressed
- User story 1
- User story 7
+30
View File
@@ -0,0 +1,30 @@
# PRD #2 - Task 3/5: Remove Klipper and Moonraker scenarios
**Tags:** `task`, `prd-2`
## Parent PRD
PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM (`docs/prd/PRD-002-live-vm-test-strategy.md`)
## What to build
Add remove-Klipper and install/remove-Moonraker scenarios. Each scenario starts from a clean snapshot. Capture relevant VM logs when a scenario fails to make debugging actionable.
## Acceptance criteria
- [ ] Remove Klipper scenario runs and verifies service/files are gone.
- [ ] Install Moonraker scenario runs and verifies service/config/log files.
- [ ] Remove Moonraker scenario runs and verifies cleanup.
- [ ] Failure output includes tail of installer/service logs.
## Blocked by
- TODO-007 (PRD #2 - Task 2/5: Scenario loader and Klipper install scenario)
## Next task
- TODO-009 (PRD #2 - Task 4/5: Mainsail and Fluidd install scenarios)
## User stories addressed
- User story 2
+30
View File
@@ -0,0 +1,30 @@
# PRD #2 - Task 4/5: Mainsail and Fluidd install scenarios
**Tags:** `task`, `prd-2`
## Parent PRD
PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM (`docs/prd/PRD-002-live-vm-test-strategy.md`)
## What to build
Add install scenarios for Mainsail and Fluidd web clients. Assert that the static files are deployed and the reverse-proxy/service config is in place. Harden the snapshot-revert fixture so it runs reliably before every scenario.
## Acceptance criteria
- [ ] Mainsail install scenario passes on the VM.
- [ ] Fluidd install scenario passes on the VM.
- [ ] Expected outcomes check webroot directory and reverse-proxy config.
- [ ] Snapshot revert fixture is robust (wait for SSH, error on revert failure).
## Blocked by
- TODO-008 (PRD #2 - Task 3/5: Remove Klipper and Moonraker scenarios)
## Next task
- TODO-010 (PRD #2 - Task 5/5: Backup, restore, and update scenarios)
## User stories addressed
- User story 3
+32
View File
@@ -0,0 +1,32 @@
# PRD #2 - Task 5/5: Backup, restore, and update scenarios
**Tags:** `task`, `prd-2`
## Parent PRD
PRD #2: Isolated live-system acceptance test strategy on Debian 12 VM (`docs/prd/PRD-002-live-vm-test-strategy.md`)
## What to build
Add backup/restore and update scenarios. Document the live test runbook: how to prepare the VM, set inventory, run scenarios, and read results. Ensure the whole live suite can be executed in one command.
## Acceptance criteria
- [ ] Backup scenario creates an archive with expected content.
- [ ] Restore scenario returns config files to expected state.
- [ ] Update scenario changes a component version/config observable on the VM.
- [ ] Runbook `docs/live-testing.md` covers VM setup, inventory, execution, and troubleshooting.
- [ ] Full `pytest -m live` run completes end-to-end.
## Blocked by
- TODO-009 (PRD #2 - Task 4/5: Mainsail and Fluidd install scenarios)
## Next task
None — this is the last task.
## User stories addressed
- User story 4
- User story 7
+5 -7
View File
@@ -135,12 +135,10 @@ function main() {
export PYTHONPATH="${entrypoint}"
clear -x
python3 "${entrypoint}/kiauh/main.py" "$@"
python3 "${entrypoint}/kiauh/main.py"
}
# skip update prompt when arguments are passed -> dont block cli runs
if [[ $# -eq 0 ]]; then
kiauh_update_dialog
fi
main "$@"
check_if_ratos
check_euid
kiauh_update_dialog
main
+25 -36
View File
@@ -27,7 +27,9 @@ from components.klipper.klipper_dialogs import (
print_select_instance_count_dialog,
)
from components.webui_client.base_data import BaseWebClient
from components.webui_client.client_utils import create_client_config_symlink
from components.webui_client.client_config.client_config_setup import (
create_client_config_symlink,
)
from core.constants import CURRENT_USER
from core.instance_manager.base_instance import SUFFIX_BLACKLIST
from core.logger import DialogType, Logger
@@ -86,45 +88,33 @@ def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None:
name_dict[key] = get_string_input(question, exclude=existing_names, regex=pattern)
def check_user_groups(interactive: bool = True) -> None:
"""Ensure the current user is in the ``tty`` and ``dialout`` groups.
When ``interactive`` is true (the TUI path), the user is shown a dialog and
must confirm before groups are modified. When ``interactive`` is false (the
headless CLI path), groups are added automatically without prompting.
"""
def check_user_groups() -> None:
user_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()]
missing_groups = [g for g in ["tty", "dialout"] if g not in user_groups]
if not missing_groups:
return
if interactive:
Logger.print_dialog(
DialogType.ATTENTION,
[
"Your current user is not in group:",
*[f"{g}" for g in missing_groups],
"\n\n",
"It is possible that you won't be able to successfully connect and/or "
"flash the controller board without your user being a member of that "
"group. If you want to add the current user to the group(s) listed above, "
"answer with 'Y'. Else skip with 'n'.",
"\n\n",
"INFO:",
"Relog required for group assignments to take effect!",
],
)
Logger.print_dialog(
DialogType.ATTENTION,
[
"Your current user is not in group:",
*[f"{g}" for g in missing_groups],
"\n\n",
"It is possible that you won't be able to successfully connect and/or "
"flash the controller board without your user being a member of that "
"group. If you want to add the current user to the group(s) listed above, "
"answer with 'Y'. Else skip with 'n'.",
"\n\n",
"INFO:",
"Relog required for group assignments to take effect!",
],
)
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
log = "Skipped adding user to required groups. You might encounter issues."
Logger.print_warn(log)
return
else:
Logger.print_info(
f"Adding user '{CURRENT_USER}' to required groups: "
f"{', '.join(missing_groups)}"
)
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
log = "Skipped adding user to required groups. You might encounter issues."
Logger.print_warn(log)
return
try:
for group in missing_groups:
@@ -136,9 +126,8 @@ def check_user_groups(interactive: bool = True) -> None:
Logger.print_error(f"Unable to add user to usergroups: {e}")
raise
if interactive:
log = "Remember to relog/restart this machine for the group(s) to be applied!"
Logger.print_warn(log)
log = "Remember to relog/restart this machine for the group(s) to be applied!"
Logger.print_warn(log)
def handle_disruptive_system_packages() -> None:
@@ -8,7 +8,6 @@
# ======================================================================= #
from __future__ import annotations
import traceback
from copy import copy
from typing import Dict, List, Tuple
@@ -95,234 +94,132 @@ class KlipperSetupService:
self.msgsvc = MessageService()
def _refresh_state(self) -> None:
def __refresh_state(self) -> None:
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
def install(
self,
count: int | None = None,
custom_names: Dict[int, str] | None = None,
create_example_cfg: bool | None = None,
match_moonraker: bool = False,
interactive: bool = True,
) -> bool:
"""Install Klipper.
When called without arguments from the TUI, all choices are prompted
interactively. The CLI passes explicit values and ``interactive=False``.
Returns ``True`` on success and ``False`` when installation cannot proceed.
"""
self._refresh_state()
def install(self) -> None:
self.__refresh_state()
Logger.print_status("Installing Klipper ...")
name_dict: Dict[int, str] = {}
match_moonraker: bool = False
if custom_names is not None:
name_dict = custom_names
elif match_moonraker and len(self.moonraker_list) > len(self.klipper_list):
if interactive:
if not self._display_moonraker_info():
Logger.print_status(EXIT_KLIPPER_SETUP)
return True
name_dict = {
i: moonraker.suffix for i, moonraker in enumerate(self.moonraker_list)
}
elif count is not None:
name_dict = {i: "" for i in range(count)}
elif interactive:
install_count, name_dict = self.__get_install_count_and_name_dict()
if install_count == 0:
# if there are more moonraker instances than klipper instances, ask the user to
# match the klipper instance count to the count of moonraker instances with the same suffix
if len(self.moonraker_list) > len(self.klipper_list):
is_confirmed = self.__display_moonraker_info()
if not is_confirmed:
Logger.print_status(EXIT_KLIPPER_SETUP)
return True
return
match_moonraker = True
is_multi_install = install_count > 1 or (
len(name_dict) >= 1 and install_count >= 1
)
if not name_dict and install_count == 1:
name_dict = {0: ""}
elif is_multi_install and not self.__count_from_moonraker_match(
install_count, name_dict
):
use_custom_names = self.__use_custom_names_or_go_back()
if use_custom_names is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return True
install_count, name_dict = self.__get_install_count_and_name_dict()
self.__handle_instance_names(install_count, name_dict, use_custom_names)
else:
name_dict = {0: ""}
if not name_dict:
if install_count == 0:
Logger.print_status(EXIT_KLIPPER_SETUP)
return True
return
if create_example_cfg is None:
create_example_cfg = (
get_confirm("Create example printer.cfg?") if interactive else False
)
is_multi_install = install_count > 1 or (
len(name_dict) >= 1 and install_count >= 1
)
if not name_dict and install_count == 1:
name_dict = {0: ""}
elif is_multi_install and not match_moonraker:
custom_names = self.__use_custom_names_or_go_back()
if custom_names is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
self.__handle_instance_names(install_count, name_dict, custom_names)
create_example_cfg = get_confirm("Create example printer.cfg?")
# run the actual installation
try:
self.__run_setup(name_dict, create_example_cfg, interactive=interactive)
except Exception:
Logger.print_error(traceback.format_exc())
self.__run_setup(name_dict, create_example_cfg)
except Exception as e:
Logger.print_error(e)
Logger.print_error("Klipper installation failed!")
return False
return
return True
def update(self) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"Do NOT continue if there are ongoing prints running!",
"All Klipper instances will be restarted during the update process and "
"ongoing prints WILL FAIL.",
],
)
def update(self, interactive: bool = True) -> bool:
"""Update Klipper.
if not get_confirm("Update Klipper now?"):
return
When called from the TUI, a warning and confirmation are shown. The CLI
passes ``interactive=False`` to run silently.
self.__refresh_state()
Returns ``True`` on success and ``False`` if the update could not be completed.
"""
if interactive:
Logger.print_dialog(
DialogType.WARNING,
[
"Do NOT continue if there are ongoing prints running!",
"All Klipper instances will be restarted during the update process and "
"ongoing prints WILL FAIL.",
],
)
if self.settings.kiauh.backup_before_update:
backup_klipper_dir()
if not get_confirm("Update Klipper now?"):
return False
self._refresh_state()
try:
if self.settings.kiauh.backup_before_update:
backup_klipper_dir()
InstanceManager.stop_all(self.klipper_list)
git_pull_wrapper(KLIPPER_DIR)
install_klipper_packages()
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
InstanceManager.start_all(self.klipper_list)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while updating Klipper!")
return False
return True
InstanceManager.stop_all(self.klipper_list)
git_pull_wrapper(KLIPPER_DIR)
install_klipper_packages()
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
InstanceManager.start_all(self.klipper_list)
def remove(
self,
remove_service: bool,
remove_dir: bool,
remove_env: bool,
*,
remove_all: bool = False,
instance_suffixes: List[str] | None = None,
interactive: bool = True,
) -> bool:
"""Remove Klipper.
) -> None:
self.__refresh_state()
When called from the TUI, the user selects instances interactively. In
headless mode (``interactive=False``) the caller MUST express explicit
intent: pass ``remove_all=True`` to wipe every instance or
``instance_suffixes=[...]`` to remove a named subset. Without explicit
intent the service refuses and removes nothing so a CLI user can never
accidentally destroy every Klipper instance on the machine.
completion_msg = Message(
title="Klipper Removal Process completed",
color=Color.GREEN,
)
Returns ``True`` on success and ``False`` if removal could not be completed.
"""
self._refresh_state()
try:
if interactive:
completion_msg = Message(
title="Klipper Removal Process completed",
color=Color.GREEN,
)
if remove_service:
Logger.print_status("Removing Klipper instances ...")
if self.klipper_list:
instances_to_remove = self._get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
]
txt = f"● Klipper instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info("No Klipper Services installed! Skipped ...")
if (remove_dir or remove_env) and unit_file_exists(
"klipper", suffix="service"
):
completion_msg.text = [
"Some Klipper services are still installed:",
f"'{KLIPPER_DIR}' was not removed, even though selected for removal.",
f"'{KLIPPER_ENV_DIR}' was not removed, even though selected for removal.",
if remove_service:
Logger.print_status("Removing Klipper instances ...")
if self.klipper_list:
instances_to_remove = self.__get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
]
else:
if remove_dir:
Logger.print_status("Removing Klipper local repository ...")
if run_remove_routines(KLIPPER_DIR):
completion_msg.text.append(
"● Klipper local repository removed"
)
if remove_env:
Logger.print_status("Removing Klipper Python environment ...")
if run_remove_routines(KLIPPER_ENV_DIR):
completion_msg.text.append(
"● Klipper Python environment removed"
)
if completion_msg.text:
completion_msg.text.insert(
0, "The following actions were performed:"
)
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
self.msgsvc.set_message(completion_msg)
txt = f"● Klipper instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
if remove_service and self.klipper_list:
selected = self._select_instances_for_headless_removal(
remove_all, instance_suffixes
)
if selected is None:
Logger.print_error(
"Refusing to remove Klipper instances: no explicit "
"intent. Pass remove_all=True or instance_suffixes."
)
return False
self.__remove_instances(selected)
Logger.print_info("No Klipper Services installed! Skipped ...")
if (remove_dir or remove_env) and unit_file_exists(
"klipper", suffix="service"
):
Logger.print_info(
"Klipper services still installed; skipping repository/env removal."
)
return True
if (remove_dir or remove_env) and unit_file_exists("klipper", suffix="service"):
completion_msg.text = [
"Some Klipper services are still installed:",
f"'{KLIPPER_DIR}' was not removed, even though selected for removal.",
f"'{KLIPPER_ENV_DIR}' was not removed, even though selected for removal.",
]
else:
if remove_dir:
Logger.print_status("Removing Klipper local repository ...")
if run_remove_routines(KLIPPER_DIR):
completion_msg.text.append("● Klipper local repository removed")
if remove_env:
Logger.print_status("Removing Klipper Python environment ...")
if run_remove_routines(KLIPPER_ENV_DIR):
completion_msg.text.append("● Klipper Python environment removed")
if remove_dir:
run_remove_routines(KLIPPER_DIR)
if remove_env:
run_remove_routines(KLIPPER_ENV_DIR)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while removing Klipper!")
return False
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
return True
self.msgsvc.set_message(completion_msg)
def __get_install_count_and_name_dict(self) -> Tuple[int, Dict[int, str]]:
install_count: int | None
@@ -343,16 +240,9 @@ class KlipperSetupService:
return install_count, name_dict
def __run_setup(
self,
name_dict: Dict[int, str],
create_example_cfg: bool,
interactive: bool = True,
) -> None:
def __run_setup(self, name_dict: Dict[int, str], create_example_cfg: bool) -> None:
if not self.klipper_list:
# Only create a fresh venv when none exists; existing venvs are
# preserved in both TUI and CLI modes.
self.__install_deps(interactive=interactive)
self.__install_deps()
for i in name_dict:
# skip this iteration if there is already an instance with the name
@@ -376,9 +266,9 @@ class KlipperSetupService:
handle_disruptive_system_packages()
# step 5: check for required group membership
check_user_groups(interactive=interactive)
check_user_groups()
def __install_deps(self, interactive: bool = True) -> None:
def __install_deps(self) -> None:
default_repo = (KLIPPER_REPO_URL, "master")
repo = self.settings.klipper.repositories
# pull the first repo defined in kiauh.cfg or fallback to the official Klipper repo
@@ -387,19 +277,13 @@ class KlipperSetupService:
try:
install_klipper_packages()
if create_python_venv(
KLIPPER_ENV_DIR,
force=False,
allow_access_to_system_site_packages=False,
use_python_binary=self.settings.klipper.use_python_binary,
interactive=interactive,
):
if create_python_venv(KLIPPER_ENV_DIR, False, False, self.settings.klipper.use_python_binary):
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
except Exception:
Logger.print_error("Error during installation of Klipper requirements!")
raise
def _display_moonraker_info(self) -> bool:
def __display_moonraker_info(self) -> bool:
# todo: only show the klipper instances that are not already installed
Logger.print_dialog(
DialogType.INFO,
@@ -424,17 +308,6 @@ class KlipperSetupService:
else:
name_dict[key] = str(len(name_dict) + 1)
def __count_from_moonraker_match(
self, install_count: int, name_dict: Dict[int, str]
) -> bool:
"""Return True when the count/names came from matching Moonraker instances."""
if len(self.moonraker_list) <= len(self.klipper_list):
return False
if install_count != len(self.moonraker_list):
return False
expected = [m.suffix for m in self.moonraker_list]
return list(name_dict.values()) == expected
def __use_custom_names_or_go_back(self) -> bool | None:
print_select_custom_name_dialog()
_input: bool | None = get_confirm(
@@ -444,7 +317,7 @@ class KlipperSetupService:
)
return _input
def _get_instances_to_remove(self) -> List[Klipper] | None:
def __get_instances_to_remove(self) -> List[Klipper] | None:
start_index = 1
curr_instances: List[Klipper] = self.klipper_list
instance_count = len(curr_instances)
@@ -468,26 +341,6 @@ class KlipperSetupService:
return [instance_map[selection]]
def _select_instances_for_headless_removal(
self,
remove_all: bool,
instance_suffixes: List[str] | None,
) -> List[Klipper] | None:
"""Resolve which instances to remove in headless mode.
Returns the list of instances to remove, or ``None`` when the caller did
not express explicit intent (no ``remove_all`` and no ``instance_suffixes``).
A ``None`` return is the "refuse to wipe everything" signal the CLI path
relies on. Kept as a single-public-seam helper (no name mangling) so
tests can patch it without brittle ``_Class__method`` access.
"""
if remove_all:
return list(self.klipper_list)
if instance_suffixes:
wanted = set(instance_suffixes)
return [i for i in self.klipper_list if i.suffix in wanted]
return None
def __remove_instances(
self,
instance_list: List[Klipper] | None,
@@ -500,11 +353,11 @@ class KlipperSetupService:
f"Removing instance {instance.service_file_path.stem} ..."
)
InstanceManager.remove(instance)
self._delete_klipper_env_file(instance)
self.__delete_klipper_env_file(instance)
self._refresh_state()
self.__refresh_state()
def _delete_klipper_env_file(self, instance: Klipper):
def __delete_klipper_env_file(self, instance: Klipper):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
@@ -1,445 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from components.klipper.services.klipper_setup_service import KlipperSetupService
@pytest.fixture
def reset_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(KlipperSetupService, "_KlipperSetupService__cls_instance", None)
class FakeKlipper:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.create_calls: List[Any] = []
def create(self) -> None:
self.create_calls.append(True)
@pytest.fixture
def patched_install_deps(
monkeypatch: pytest.MonkeyPatch, reset_service
) -> Dict[str, List[Any]]:
calls: Dict[str, List[Any]] = {
"klipper_create": [],
"enable": [],
"start": [],
}
module = "components.klipper.services.klipper_setup_service"
def fake_klipper(suffix: str = "") -> FakeKlipper:
instance = FakeKlipper(suffix)
calls["klipper_create"].append(instance)
return instance
monkeypatch.setattr(f"{module}.Klipper", fake_klipper)
monkeypatch.setattr(
f"{module}.InstanceManager.enable",
staticmethod(lambda instance: calls["enable"].append(instance.suffix)),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start",
staticmethod(lambda instance: calls["start"].append(instance.suffix)),
)
monkeypatch.setattr(f"{module}.git_clone_wrapper", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.install_klipper_packages", lambda: None)
monkeypatch.setattr(f"{module}.create_python_venv", lambda *a, **k: True)
monkeypatch.setattr(f"{module}.install_python_requirements", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.handle_disruptive_system_packages", lambda: None)
monkeypatch.setattr(f"{module}.check_user_groups", lambda interactive=True: None)
monkeypatch.setattr(f"{module}.cmd_sysctl_manage", lambda *a, **k: None)
return calls
class TestKlipperInstallHeadless:
def test_installs_single_instance_by_default(
self, patched_install_deps, monkeypatch
) -> None:
service = KlipperSetupService()
service.install(interactive=False)
assert len(patched_install_deps["klipper_create"]) == 1
assert patched_install_deps["enable"] == [""]
assert patched_install_deps["start"] == [""]
def test_installs_multiple_instances_by_count(
self, patched_install_deps, monkeypatch
) -> None:
service = KlipperSetupService()
service.install(count=2, interactive=False)
assert len(patched_install_deps["klipper_create"]) == 2
assert patched_install_deps["enable"] == ["", ""]
assert patched_install_deps["start"] == ["", ""]
def test_installs_with_custom_names(
self, patched_install_deps, monkeypatch
) -> None:
service = KlipperSetupService()
service.install(custom_names={0: "a", 1: "b"}, interactive=False)
assert len(patched_install_deps["klipper_create"]) == 2
instances = patched_install_deps["klipper_create"]
assert instances[0].suffix == "a"
assert instances[1].suffix == "b"
class TestKlipperRemoveHeadless:
def _make_fake_instance(self, suffix: str = ""):
Path = __import__("pathlib").Path
return type(
"FakeInstance",
(),
{
"suffix": suffix,
"service_file_path": Path(f"klipper-{suffix}.service"),
"env_file": Path("/tmp/klipper.env"),
"base": type("Base", (), {"sysd_dir": Path("/tmp")})(),
},
)()
def _patch_remove_internals(self, monkeypatch, removed):
module = "components.klipper.services.klipper_setup_service"
monkeypatch.setattr(
f"{module}.KlipperSetupService._refresh_state",
lambda self: None,
)
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance.suffix)),
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
def test_removes_explicit_all_services_and_files(
self, reset_service, monkeypatch
) -> None:
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
fake_instance = self._make_fake_instance("")
service = KlipperSetupService()
service.klipper_list = [fake_instance]
service.remove(
remove_service=True,
remove_dir=True,
remove_env=True,
remove_all=True,
interactive=False,
)
assert removed["instances"] == [""]
assert any("klipper" in p for p in removed["paths"])
def test_without_explicit_intent_removes_nothing(
self, reset_service, monkeypatch
) -> None:
# non-interactive remove with no --all and no --instance must
# NOT call InstanceManager.remove or run_remove_routines and must
# refuse with a non-zero (False) result.
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
fake_instance = self._make_fake_instance("a")
service = KlipperSetupService()
service.klipper_list = [fake_instance]
result = service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
interactive=False,
)
assert result is False
assert removed["instances"] == []
assert removed["paths"] == []
def test_with_instance_suffix_removes_only_matching(
self, reset_service, monkeypatch
) -> None:
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = KlipperSetupService()
service.klipper_list = [
self._make_fake_instance("a"),
self._make_fake_instance("b"),
]
service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
instance_suffixes=["a"],
interactive=False,
)
assert removed["instances"] == ["a"]
class TestKlipperUpdateHeadless:
def test_update_runs_expected_steps(self, reset_service, monkeypatch) -> None:
module = "components.klipper.services.klipper_setup_service"
calls: List[str] = []
monkeypatch.setattr(
f"{module}.backup_klipper_dir", lambda: calls.append("backup")
)
monkeypatch.setattr(
f"{module}.InstanceManager.stop_all",
staticmethod(lambda instances: calls.append("stop")),
)
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
)
monkeypatch.setattr(
f"{module}.install_klipper_packages", lambda: calls.append("packages")
)
monkeypatch.setattr(
f"{module}.install_python_requirements",
lambda *a, **k: calls.append("requirements"),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start_all",
staticmethod(lambda instances: calls.append("start")),
)
service = KlipperSetupService()
service.settings.kiauh.backup_before_update = True
result = service.update(interactive=False)
assert result is True
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
def test_update_cancelled_by_user_returns_false(
self, reset_service, monkeypatch
) -> None:
module = "components.klipper.services.klipper_setup_service"
pulled: List[str] = []
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
service = KlipperSetupService()
result = service.update(interactive=True)
assert result is False
assert pulled == []
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
class TestKlipperInteractiveMoonrakerMatch:
def test_installs_exactly_one_klipper_per_moonraker(
self, reset_service, patched_install_deps, monkeypatch
) -> None:
module = "components.klipper.services.klipper_setup_service"
monkeypatch.setattr(
f"{module}.KlipperSetupService._refresh_state",
lambda self: None,
)
monkeypatch.setattr(
f"{module}.KlipperSetupService._display_moonraker_info",
lambda self: True,
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
service = KlipperSetupService()
service.klipper_list = []
service.moonraker_list = [FakeMoonraker(""), FakeMoonraker("b")]
result = service.install(interactive=True, create_example_cfg=False)
assert result is True
assert len(patched_install_deps["klipper_create"]) == 2
instances = patched_install_deps["klipper_create"]
assert instances[0].suffix == ""
assert instances[1].suffix == "b"
def test_headless_match_moonraker_skips_dialog(
self, reset_service, patched_install_deps, monkeypatch
) -> None:
module = "components.klipper.services.klipper_setup_service"
monkeypatch.setattr(
f"{module}.KlipperSetupService._refresh_state",
lambda self: None,
)
dialog_calls: List[Any] = []
monkeypatch.setattr(
f"{module}.KlipperSetupService._display_moonraker_info",
lambda self: dialog_calls.append(True) or False,
)
service = KlipperSetupService()
service.klipper_list = []
service.moonraker_list = [FakeMoonraker("a"), FakeMoonraker("b")]
result = service.install(match_moonraker=True, interactive=False)
assert result is True
assert dialog_calls == []
assert len(patched_install_deps["klipper_create"]) == 2
instances = patched_install_deps["klipper_create"]
assert [i.suffix for i in instances] == ["a", "b"]
class TestKlipperVenvNonDestructive:
"""a headless install must not force-recreate an existing Klipper
venv. ``__install_deps`` must pass ``force=False`` and ``interactive=False``
to ``create_python_venv`` so an existing venv is left untouched (no prompt,
no ``rmtree``)."""
def test_headless_install_does_not_force_recreate_venv(
self, reset_service, monkeypatch
) -> None:
module = "components.klipper.services.klipper_setup_service"
monkeypatch.setattr(
f"{module}.KlipperSetupService._refresh_state",
lambda self: None,
)
venv_calls: List[Any] = []
monkeypatch.setattr(
f"{module}.create_python_venv",
lambda *a, **k: venv_calls.append(k) or True,
)
monkeypatch.setattr(f"{module}.git_clone_wrapper", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.install_klipper_packages", lambda: None)
monkeypatch.setattr(
f"{module}.install_python_requirements", lambda *a, **k: None
)
service = KlipperSetupService()
service.klipper_list = []
service.install(interactive=False)
assert venv_calls, "create_python_venv should have been called"
assert venv_calls[0]["force"] is False
assert venv_calls[0]["interactive"] is False
class TestCheckUserGroups:
def test_interactive_mode_prompts_before_adding_user(self, monkeypatch) -> None:
from components.klipper.klipper_utils import check_user_groups
monkeypatch.setattr("os.getgroups", lambda: [])
monkeypatch.setattr(
"grp.getgrgid",
lambda gid: type("Group", (), {"gr_name": "tty"})(),
)
prompted: List[str] = []
monkeypatch.setattr(
"components.klipper.klipper_utils.get_confirm",
lambda question, *a, **k: prompted.append(question) or True,
)
run_calls: List[List[str]] = []
monkeypatch.setattr(
"components.klipper.klipper_utils.run",
lambda cmd, **kwargs: (
run_calls.append(cmd) or type("R", (), {"returncode": 0})()
),
)
check_user_groups(interactive=True)
assert any("group" in q.lower() for q in prompted)
assert run_calls
def test_headless_mode_auto_adds_without_prompt(self, monkeypatch) -> None:
from components.klipper.klipper_utils import check_user_groups
monkeypatch.setattr("os.getgroups", lambda: [])
monkeypatch.setattr(
"grp.getgrgid",
lambda gid: type("Group", (), {"gr_name": "tty"})(),
)
monkeypatch.setattr(
"components.klipper.klipper_utils.get_confirm",
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
)
run_calls: List[List[str]] = []
monkeypatch.setattr(
"components.klipper.klipper_utils.run",
lambda cmd, **kwargs: (
run_calls.append(cmd) or type("R", (), {"returncode": 0})()
),
)
check_user_groups(interactive=False)
assert run_calls
class TestKlipperRemoveInteractiveTui:
"""Exercise the interactive (TUI) remove branch so the message-assembly
path stays covered: the TUI path must remain unchanged."""
def _make_fake_instance(self, suffix: str = ""):
Path = __import__("pathlib").Path
return type(
"FakeInstance",
(),
{
"suffix": suffix,
"service_file_path": Path(f"klipper-{suffix}.service"),
"env_file": Path("/tmp/klipper.env"),
"base": type("Base", (), {"sysd_dir": Path("/tmp")})(),
},
)()
def test_interactive_remove_sets_completion_message(
self, reset_service, monkeypatch
) -> None:
module = "components.klipper.services.klipper_setup_service"
fake_instance = self._make_fake_instance("a")
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
monkeypatch.setattr(
f"{module}.KlipperSetupService._refresh_state",
lambda self: None,
)
monkeypatch.setattr(
f"{module}.KlipperSetupService._get_instances_to_remove",
lambda self: [fake_instance],
)
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance)),
)
monkeypatch.setattr(
f"{module}.KlipperSetupService._delete_klipper_env_file",
lambda self, inst: None,
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
set_messages: List[Any] = []
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type(
"MS", (), {"set_message": lambda self, m: set_messages.append(m)}
)(),
)
service = KlipperSetupService()
service.klipper_list = [fake_instance]
result = service.remove(
remove_service=True, remove_dir=True, remove_env=True, interactive=True
)
assert result is True
assert removed["instances"] == [fake_instance]
assert set_messages, "TUI remove must set the completion message"
assert any("klipper-a" in line for line in set_messages[0].text)
@@ -8,9 +8,8 @@
# ======================================================================= #
from __future__ import annotations
import traceback
from copy import copy
from subprocess import DEVNULL, PIPE, run
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from typing import List
from components.klipper.klipper import Klipper
@@ -105,128 +104,87 @@ class MoonrakerSetupService:
self.msgsvc = MessageService()
def _refresh_state(self) -> None:
def __refresh_state(self) -> None:
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
def install(
self,
klipper_suffixes: List[str] | None = None,
create_example_cfg: bool | None = None,
interactive: bool = True,
) -> bool:
"""Install Moonraker.
def install(self) -> None:
self.__refresh_state()
When called from the TUI, the Klipper instance is selected interactively.
The CLI passes explicit suffixes and ``interactive=False``.
Returns ``True`` on success and ``False`` when installation cannot proceed.
"""
self._refresh_state()
if not self._check_requirements(self.klipper_list):
return False
if not self.__check_requirements(self.klipper_list):
return
new_instances: List[Moonraker] = []
selected_option: str | Klipper
if klipper_suffixes is not None:
for suffix in klipper_suffixes:
new_instances.append(self.misvc.create_new_instance(suffix))
elif interactive:
selected_option: str | Klipper
if len(self.klipper_list) == 1:
suffix: str = self.klipper_list[0].suffix
new_inst = self.misvc.create_new_instance(suffix)
new_instances.append(new_inst)
if len(self.klipper_list) == 1:
selected_suffix: str = self.klipper_list[0].suffix
new_instances.append(self.misvc.create_new_instance(selected_suffix))
else:
print_moonraker_overview(
self.klipper_list,
self.moonraker_list,
show_index=True,
show_select_all=True,
)
options = {str(i + 1): k for i, k in enumerate(self.klipper_list)}
additional_options = {"a": None, "b": None}
options = {**options, **additional_options}
question = "Select Klipper instance to setup Moonraker for"
selected_option = get_selection_input(question, options)
if selected_option == "b":
Logger.print_status(EXIT_MOONRAKER_SETUP)
return True
if selected_option == "a":
new_inst_list: List[Moonraker] = [
self.misvc.create_new_instance(k.suffix)
for k in self.klipper_list
]
new_instances.extend(new_inst_list)
else:
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
new_instances.append(
self.misvc.create_new_instance(klipper_instance.suffix)
)
else:
for k in self.klipper_list:
new_instances.append(self.misvc.create_new_instance(k.suffix))
if create_example_cfg is None:
create_example_cfg = (
get_confirm("Create example moonraker.conf?") if interactive else False
print_moonraker_overview(
self.klipper_list,
self.moonraker_list,
show_index=True,
show_select_all=True,
)
options = {str(i + 1): k for i, k in enumerate(self.klipper_list)}
additional_options = {"a": None, "b": None}
options = {**options, **additional_options}
question = "Select Klipper instance to setup Moonraker for"
selected_option = get_selection_input(question, options)
if selected_option == "b":
Logger.print_status(EXIT_MOONRAKER_SETUP)
return
if selected_option == "a":
new_inst_list: List[Moonraker] = [
self.misvc.create_new_instance(k.suffix) for k in self.klipper_list
]
new_instances.extend(new_inst_list)
else:
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
new_inst = self.misvc.create_new_instance(klipper_instance.suffix)
new_instances.append(new_inst)
create_example_cfg = get_confirm("Create example moonraker.conf?")
try:
self._run_setup(new_instances, create_example_cfg, interactive=interactive)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while installing Moonraker!")
return False
self.__run_setup(new_instances, create_example_cfg)
except Exception as e:
Logger.print_error(f"Error while installing Moonraker: {e}")
return
return True
def update(self) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"Be careful if there are ongoing prints running!",
"All Moonraker instances will be restarted during the update process and "
"ongoing prints COULD FAIL.",
],
)
def update(self, interactive: bool = True) -> bool:
"""Update Moonraker.
if not get_confirm("Update Moonraker now?"):
return
When called from the TUI, a warning and confirmation are shown. The CLI
passes ``interactive=False`` to run silently.
self.__refresh_state()
Returns ``True`` on success and ``False`` if the update could not be completed.
"""
if interactive:
Logger.print_dialog(
DialogType.WARNING,
[
"Be careful if there are ongoing prints running!",
"All Moonraker instances will be restarted during the update process and "
"ongoing prints COULD FAIL.",
],
)
if self.settings.kiauh.backup_before_update:
backup_moonraker_dir()
if not get_confirm("Update Moonraker now?"):
return False
self._refresh_state()
try:
if self.settings.kiauh.backup_before_update:
backup_moonraker_dir()
InstanceManager.stop_all(self.moonraker_list)
git_pull_wrapper(MOONRAKER_DIR)
install_moonraker_packages()
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
InstanceManager.start_all(self.moonraker_list)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while updating Moonraker!")
return False
return True
InstanceManager.stop_all(self.moonraker_list)
git_pull_wrapper(MOONRAKER_DIR)
install_moonraker_packages()
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
InstanceManager.start_all(self.moonraker_list)
def remove(
self,
@@ -234,132 +192,65 @@ class MoonrakerSetupService:
remove_dir: bool,
remove_env: bool,
remove_polkit: bool,
*,
remove_all: bool = False,
instance_suffixes: List[str] | None = None,
interactive: bool = True,
) -> bool:
"""Remove Moonraker.
) -> None:
self.__refresh_state()
When called from the TUI, the user selects instances interactively. In
headless mode (``interactive=False``) the caller MUST express explicit
intent: pass ``remove_all=True`` to wipe every instance or
``instance_suffixes=[...]`` to remove a named subset. Without explicit
intent the service refuses and removes nothing, so a CLI user can never
accidentally destroy every Moonraker instance.
completion_msg = Message(
title="Moonraker Removal Process completed",
color=Color.GREEN,
)
Returns ``True`` on success and ``False`` if removal could not be completed.
"""
self._refresh_state()
try:
if interactive:
completion_msg = Message(
title="Moonraker Removal Process completed",
color=Color.GREEN,
)
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if self.moonraker_list:
selected = self._get_instances_to_remove()
self.__remove_instances(selected)
if selected:
instance_names = [
i.service_file_path.stem for i in selected
]
txt = f"● Moonraker instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info(
"No Moonraker Services installed! Skipped ..."
)
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
):
completion_msg.text = [
"Some Klipper services are still installed:",
"● Moonraker PolicyKit rules were not removed, even though selected for removal.",
f"'{MOONRAKER_DIR}' was not removed, even though selected for removal.",
f"'{MOONRAKER_ENV_DIR}' was not removed, even though selected for removal.",
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if self.moonraker_list:
instances_to_remove = self.__get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
]
else:
if remove_polkit:
Logger.print_status(
"Removing all Moonraker policykit rules ..."
)
if remove_polkit_rules():
completion_msg.text.append(
"● Moonraker policykit rules removed"
)
if remove_dir:
Logger.print_status("Removing Moonraker local repository ...")
if run_remove_routines(MOONRAKER_DIR):
completion_msg.text.append(
"● Moonraker local repository removed"
)
if remove_env:
Logger.print_status("Removing Moonraker Python environment ...")
if run_remove_routines(MOONRAKER_ENV_DIR):
completion_msg.text.append(
"● Moonraker Python environment removed"
)
if completion_msg.text:
completion_msg.text.insert(
0, "The following actions were performed:"
)
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
self.msgsvc.set_message(completion_msg)
txt = f"● Moonraker instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
if remove_service and self.moonraker_list:
selected = self._select_instances_for_headless_removal(
remove_all, instance_suffixes
)
if selected is None:
Logger.print_error(
"Refusing to remove Moonraker instances: no explicit "
"intent. Pass remove_all=True or instance_suffixes."
)
return False
self.__remove_instances(selected)
Logger.print_info("No Moonraker Services installed! Skipped ...")
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
):
Logger.print_info(
"Moonraker services still installed; skipping repository/env removal."
)
return True
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
):
completion_msg.text = [
"Some Klipper services are still installed:",
"● Moonraker PolicyKit rules were not removed, even though selected for removal.",
f"'{MOONRAKER_DIR}' was not removed, even though selected for removal.",
f"'{MOONRAKER_ENV_DIR}' was not removed, even though selected for removal.",
]
else:
if remove_polkit:
Logger.print_status("Removing all Moonraker policykit rules ...")
if remove_polkit_rules():
completion_msg.text.append("● Moonraker policykit rules removed")
if remove_dir:
Logger.print_status("Removing Moonraker local repository ...")
if run_remove_routines(MOONRAKER_DIR):
completion_msg.text.append("● Moonraker local repository removed")
if remove_env:
Logger.print_status("Removing Moonraker Python environment ...")
if run_remove_routines(MOONRAKER_ENV_DIR):
completion_msg.text.append("● Moonraker Python environment removed")
if remove_polkit:
remove_polkit_rules()
if remove_dir:
run_remove_routines(MOONRAKER_DIR)
if remove_env:
run_remove_routines(MOONRAKER_ENV_DIR)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while removing Moonraker!")
return False
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
return True
self.msgsvc.set_message(completion_msg)
def _run_setup(
self,
new_instances: List[Moonraker],
create_example_cfg: bool,
interactive: bool = True,
def __run_setup(
self, new_instances: List[Moonraker], create_example_cfg: bool
) -> None:
check_install_dependencies()
# Only create a fresh venv when none exists; existing venvs are
# preserved in both TUI and CLI modes.
self._install_deps(interactive=interactive)
self.__install_deps()
ports_map = self.misvc.get_instance_port_map()
for i in new_instances:
@@ -398,21 +289,14 @@ class MoonrakerSetupService:
dialog_content.append("You can access Moonraker via the following URL:")
dialog_content.extend(url_list)
if interactive:
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Moonraker successfully installed!",
custom_color=Color.GREEN,
content=dialog_content,
)
else:
if url_list:
for url in url_list:
Logger.print_info(url)
else:
Logger.print_info("Moonraker successfully installed!")
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Moonraker successfully installed!",
custom_color=Color.GREEN,
content=dialog_content,
)
def _check_requirements(self, klipper_list: List[Klipper]) -> bool:
def __check_requirements(self, klipper_list: List[Klipper]) -> bool:
is_klipper_installed = len(klipper_list) >= 1
if not is_klipper_installed:
Logger.print_warn("Klipper not installed!")
@@ -422,7 +306,7 @@ class MoonrakerSetupService:
return is_klipper_installed and is_python_ok
def _install_deps(self, interactive: bool = True) -> None:
def __install_deps(self) -> None:
default_repo = (MOONRAKER_REPO_URL, "master")
repo = self.settings.moonraker.repositories
# pull the first repo defined in kiauh.cfg or fallback to the official Moonraker repo
@@ -431,24 +315,18 @@ class MoonrakerSetupService:
try:
install_moonraker_packages()
if create_python_venv(
MOONRAKER_ENV_DIR,
force=False,
allow_access_to_system_site_packages=False,
use_python_binary=self.settings.moonraker.use_python_binary,
interactive=interactive,
):
if create_python_venv(MOONRAKER_ENV_DIR, False, False, self.settings.moonraker.use_python_binary):
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
if self.settings.moonraker.optional_speedups:
install_python_requirements(
MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE
)
self._install_polkit()
self.__install_polkit()
except Exception:
Logger.print_error("Error during installation of Moonraker requirements!")
raise
def _install_polkit(self) -> None:
def __install_polkit(self) -> None:
Logger.print_status("Installing Moonraker policykit rules ...")
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
@@ -459,23 +337,27 @@ class MoonrakerSetupService:
Logger.print_info("Moonraker policykit rules are already installed.")
return
command = [POLKIT_SCRIPT, "--disable-systemctl"]
result = run(
command,
stderr=PIPE,
stdout=DEVNULL,
text=True,
)
if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Installing Moonraker policykit rules failed!")
# Intentional fail-soft: polkit rules are optional on many systems
# and a failure here must not abort the whole Moonraker installation.
return
try:
command = [POLKIT_SCRIPT, "--disable-systemctl"]
result = run(
command,
stderr=PIPE,
stdout=DEVNULL,
text=True,
)
if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Installing Moonraker policykit rules failed!")
return
Logger.print_ok("Moonraker policykit rules successfully installed!")
Logger.print_ok("Moonraker policykit rules successfully installed!")
except CalledProcessError as e:
log = (
f"Error while installing Moonraker policykit rules: {e.stderr.decode()}"
)
Logger.print_error(log)
def _get_instances_to_remove(self) -> List[Moonraker] | None:
def __get_instances_to_remove(self) -> List[Moonraker] | None:
start_index = 1
curr_instances: List[Moonraker] = self.moonraker_list
instance_count = len(curr_instances)
@@ -501,26 +383,6 @@ class MoonrakerSetupService:
return [instance_map[selection]]
def _select_instances_for_headless_removal(
self,
remove_all: bool,
instance_suffixes: List[str] | None,
) -> List[Moonraker] | None:
"""Resolve which instances to remove in headless mode.
Returns the list of instances to remove, or ``None`` when the caller did
not express explicit intent (no ``remove_all`` and no ``instance_suffixes``).
A ``None`` return is the "refuse to wipe everything" signal the CLI path
relies on. Kept as a single-public-seam helper (no name mangling) so
tests can patch it without brittle ``_Class__method`` access.
"""
if remove_all:
return list(self.moonraker_list)
if instance_suffixes:
wanted = set(instance_suffixes)
return [i for i in self.moonraker_list if i.suffix in wanted]
return None
def __remove_instances(
self,
instance_list: List[Moonraker] | None,
@@ -535,7 +397,7 @@ class MoonrakerSetupService:
InstanceManager.remove(instance)
self.__delete_env_file(instance)
self._refresh_state()
self.__refresh_state()
def __delete_env_file(self, instance: Moonraker):
Logger.print_status(f"Remove '{instance.env_file}'")
@@ -1,4 +0,0 @@
from __future__ import annotations
@@ -1,560 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
class FakeKlipper:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
name = f"klipper-{suffix}" if suffix else "klipper"
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
name = f"moonraker-{suffix}" if suffix else "moonraker"
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
self.env_file = Path(f"/tmp/{name}.env")
self.port = 7125
self.base = type("Base", (), {"sysd_dir": Path("/tmp")})()
def create(self) -> None:
pass
class FakeKlipperInstanceService:
def __init__(self, instances: List[FakeKlipper]) -> None:
self._instances = instances
def load_instances(self) -> None:
pass
def get_all_instances(self) -> List[FakeKlipper]:
return self._instances
class FakeMoonrakerInstanceService:
def __init__(self, instances: List[FakeMoonraker]) -> None:
self._instances = instances
self.created: List[str] = []
def load_instances(self) -> None:
pass
def get_all_instances(self) -> List[FakeMoonraker]:
return self._instances
def create_new_instance(self, suffix: str) -> FakeMoonraker:
self.created.append(suffix)
return FakeMoonraker(suffix)
def get_instance_by_suffix(self, suffix: str) -> FakeMoonraker:
return FakeMoonraker(suffix)
def get_instance_port_map(self) -> Dict[str, int]:
return {}
@pytest.fixture
def reset_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MoonrakerSetupService, "_MoonrakerSetupService__cls_instance", None
)
@pytest.fixture
def patch_instance_services(
monkeypatch: pytest.MonkeyPatch, reset_service
) -> Dict[str, Any]:
state = {"klipper": [], "moonraker": []}
def make_kis(*args, **kwargs):
return FakeKlipperInstanceService(state["klipper"])
def make_mis(*args, **kwargs):
return FakeMoonrakerInstanceService(state["moonraker"])
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(f"{module}.KlipperInstanceService", make_kis)
monkeypatch.setattr(f"{module}.MoonrakerInstanceService", make_mis)
return state
class TestMoonrakerInstall:
def test_installs_for_single_klipper_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
service = MoonrakerSetupService()
service.install()
assert len(setup_calls) == 1
instances, cfg, _interactive = setup_calls[0]
assert len(instances) == 1
assert instances[0].suffix == ""
assert cfg is True
def test_installs_for_selected_klipper_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
monkeypatch.setattr(f"{module}.get_selection_input", lambda *a, **k: "1")
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
service = MoonrakerSetupService()
service.install()
assert len(setup_calls) == 1
assert setup_calls[0][0][0].suffix == "a"
class TestMoonrakerUpdate:
def test_update_runs_expected_steps(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
calls: List[str] = []
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
monkeypatch.setattr(
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
)
monkeypatch.setattr(
f"{module}.InstanceManager.stop_all",
staticmethod(lambda instances: calls.append("stop")),
)
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
)
monkeypatch.setattr(
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
)
monkeypatch.setattr(
f"{module}.install_python_requirements",
lambda *a, **k: calls.append("requirements"),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start_all",
staticmethod(lambda instances: calls.append("start")),
)
service = MoonrakerSetupService()
service.settings.kiauh.backup_before_update = True
service.update()
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
class TestMoonrakerRemove:
def test_removes_selected_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
fake_instance = FakeMoonraker("")
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._get_instances_to_remove",
lambda self: [fake_instance],
)
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance)),
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(f"{module}.remove_polkit_rules", lambda: True)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
service = MoonrakerSetupService()
service.remove(
remove_service=True, remove_dir=True, remove_env=True, remove_polkit=True
)
assert removed["instances"] == [fake_instance]
assert any("moonraker" in p for p in removed["paths"])
class TestMoonrakerInstallHeadless:
def test_headless_install_does_not_show_success_dialog(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._install_deps",
lambda self, interactive: None,
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
monkeypatch.setattr(f"{module}.cmd_sysctl_service", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.cmd_sysctl_manage", lambda *a, **k: None)
monkeypatch.setattr(
f"{module}.check_install_dependencies", lambda *a, **k: None
)
monkeypatch.setattr(f"{module}.get_ipv4_addr", lambda: "127.0.0.1")
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: pytest.fail("should not show dialog in headless install"),
)
errors: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_error",
lambda msg, *a, **k: errors.append(str(msg)),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert errors == [], f"unexpected errors: {errors}"
assert result is True
def test_installs_with_explicit_klipper_suffixes(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
service = MoonrakerSetupService()
result = service.install(klipper_suffixes=["a", "b"], interactive=False)
assert result is True
assert len(setup_calls) == 1
instances, cfg, interactive = setup_calls[0]
assert [i.suffix for i in instances] == ["a", "b"]
assert cfg is False
assert interactive is False
def test_installs_for_all_klipper_instances_when_non_interactive(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is True
assert [i.suffix for i in setup_calls[0][0]] == ["a", "b"]
assert setup_calls[0][2] is False
def test_returns_false_when_klipper_is_missing(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = []
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is False
def test_returns_false_when_setup_raises(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is False
class TestMoonrakerUpdateHeadless:
def test_update_runs_without_confirmation(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
calls: List[str] = []
monkeypatch.setattr(
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
)
monkeypatch.setattr(
f"{module}.InstanceManager.stop_all",
staticmethod(lambda instances: calls.append("stop")),
)
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
)
monkeypatch.setattr(
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
)
monkeypatch.setattr(
f"{module}.install_python_requirements",
lambda *a, **k: calls.append("requirements"),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start_all",
staticmethod(lambda instances: calls.append("start")),
)
service = MoonrakerSetupService()
service.settings.kiauh.backup_before_update = True
service.update(interactive=False)
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
def test_update_cancelled_by_user_returns_false(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
pulled: List[str] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
service = MoonrakerSetupService()
result = service.update(interactive=True)
assert result is False
assert pulled == []
class TestMoonrakerPolkitBehavior:
def test_install_polkit_failure_logs_error_and_continues(
self, patch_instance_services, monkeypatch
) -> None:
module = "components.moonraker.services.moonraker_setup_service"
class FakeResult:
returncode = 1
stderr = "polkit install failed"
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
monkeypatch.setattr(
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
)
error_messages: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_error",
lambda msg, *a, **k: error_messages.append(str(msg)),
)
service = MoonrakerSetupService()
service._install_polkit()
assert any("polkit" in m.lower() for m in error_messages)
def test_install_succeeds_when_polkit_rules_fail(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
setup_calls: List[Any] = []
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
class FakeResult:
returncode = 1
stderr = "polkit install failed"
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
monkeypatch.setattr(
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
def fake_install_deps(self, interactive: bool = True) -> None:
self._install_polkit()
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._install_deps",
fake_install_deps,
)
service = MoonrakerSetupService()
result = service.install()
assert result is True
assert len(setup_calls) == 1
class TestMoonrakerRemoveHeadless:
def _patch_remove_internals(self, monkeypatch, removed):
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance.suffix)),
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._refresh_state",
lambda self: None,
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(
f"{module}.remove_polkit_rules",
lambda: removed["paths"].append("polkit") or True,
)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
def test_removes_all_instances_when_explicit_all(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
service.remove(
remove_service=True,
remove_dir=True,
remove_env=True,
remove_polkit=True,
remove_all=True,
interactive=False,
)
assert set(removed["instances"]) == {"a", "b"}
assert "polkit" in removed["paths"]
def test_without_explicit_intent_removes_nothing(
self, patch_instance_services, monkeypatch
) -> None:
# non-interactive remove with no --all / --instance must not destroy any instance and must refuse.
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
result = service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
remove_polkit=False,
interactive=False,
)
assert result is False
assert removed["instances"] == []
assert removed["paths"] == []
def test_with_instance_suffix_removes_only_matching(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [
FakeMoonraker("a"),
FakeMoonraker("b"),
]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
remove_polkit=False,
instance_suffixes=["a"],
interactive=False,
)
assert removed["instances"] == ["a"]
@@ -1,278 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.moonraker.utils import utils as moonraker_utils
from components.moonraker.utils.utils import (
backup_moonraker_db_dir,
backup_moonraker_dir,
create_example_moonraker_conf,
get_moonraker_status,
install_moonraker_packages,
load_sysdeps_json,
remove_polkit_rules,
)
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.data_dir = Path(f"/tmp/moonraker{suffix}_data")
self.db_dir = self.data_dir.joinpath("database")
self.cfg_file = self.data_dir.joinpath("moonraker.conf")
self.base = type(
"Base",
(),
{
"cfg_dir": self.data_dir,
"comms_dir": self.data_dir.joinpath("comms"),
},
)()
@pytest.fixture
def fake_instance(tmp_path: Path) -> FakeMoonraker:
instance = FakeMoonraker("")
instance.data_dir = tmp_path / "moonraker_data"
instance.cfg_file = instance.data_dir / "moonraker.conf"
instance.db_dir = instance.data_dir / "database"
instance.base = type(
"Base",
(),
{
"cfg_dir": instance.data_dir,
"comms_dir": instance.data_dir / "comms",
},
)()
return instance
class TestGetMoonrakerStatus:
def test_delegates_to_get_install_status(self, monkeypatch: pytest.MonkeyPatch) -> None:
called: List[Any] = []
monkeypatch.setattr(
moonraker_utils,
"get_install_status",
lambda *args: called.append(args) or type("S", (), {"status": 0})(),
)
status = get_moonraker_status()
assert called
assert status.status == 0
class TestInstallMoonrakerPackages:
def test_parses_deps_json_when_present(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "moonraker_deps.json"
deps_file.write_text(json.dumps({"debian": ["pkg1", "pkg2"]}))
install_script = tmp_path / "install_moonraker.sh"
install_script.write_text("# dummy")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
deps: List[str] = []
monkeypatch.setattr(
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
)
class FakeParser:
def parse_dependencies(self, data):
return ["pkg1", "pkg2"]
monkeypatch.setattr(moonraker_utils, "SysDepsParser", FakeParser)
install_moonraker_packages()
assert "pkg1" in deps
assert "pkg2" in deps
def test_falls_back_to_install_script(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "missing.json"
install_script = tmp_path / "install_moonraker.sh"
install_script.write_text("apt-get install pkg3 pkg4\n")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
deps: List[str] = []
monkeypatch.setattr(
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
)
monkeypatch.setattr(
moonraker_utils,
"parse_packages_from_file",
lambda p: ["pkg3", "pkg4"],
)
install_moonraker_packages()
assert "pkg3" in deps
def test_raises_when_no_deps_found(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "missing.json"
install_script = tmp_path / "missing.sh"
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
with pytest.raises(ValueError):
install_moonraker_packages()
class TestRemovePolkitRules:
def test_returns_false_when_moonraker_dir_missing(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "missing")
assert remove_polkit_rules() is False
def test_returns_true_on_success(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
monkeypatch.setattr(
moonraker_utils,
"run",
lambda *a, **k: type("R", (), {"returncode": 0})(),
)
assert remove_polkit_rules() is True
def test_returns_false_on_command_failure(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
def fake_run(*a, **k):
raise moonraker_utils.CalledProcessError(1, cmd="clear")
monkeypatch.setattr(moonraker_utils, "run", fake_run)
assert remove_polkit_rules() is False
class TestCreateExampleMoonrakerConf:
def test_skips_when_config_already_exists(
self, monkeypatch: pytest.MonkeyPatch, fake_instance: FakeMoonraker
) -> None:
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
fake_instance.cfg_file.write_text("existing")
create_example_moonraker_conf(fake_instance, {})
# no changes expected
assert fake_instance.cfg_file.read_text() == "existing"
def test_creates_config_with_default_port(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
fake_instance: FakeMoonraker,
) -> None:
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
template = assets_dir / "moonraker.conf"
template.write_text(
"[server]\nport: %{PORT}%\nklippy_uds_address: %{UDS}%\n"
"[authorization]\ntrusted_clients:\n %{CLIENTS}%\n"
)
monkeypatch.setattr(moonraker_utils, "MODULE_PATH", tmp_path)
monkeypatch.setattr(
moonraker_utils, "get_ipv4_addr", lambda: "192.168.1.10"
)
create_example_moonraker_conf(fake_instance, {})
content = fake_instance.cfg_file.read_text()
assert "192.168.0.0/16" in content
class TestBackupMoonrakerDir:
def test_backs_up_repository_and_environment(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "moonraker")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_ENV_DIR", tmp_path / "env")
backup_moonraker_dir()
assert len(calls) == 2
class TestBackupMoonrakerDbDir:
def test_backs_up_db_for_each_instance(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(
moonraker_utils, "get_instances", lambda model: [FakeMoonraker("")]
)
backup_moonraker_db_dir()
assert len(calls) == 1
def test_falls_back_to_home_dirs(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
printer_data = tmp_path / "printer_data"
printer_data.mkdir()
printer_data.joinpath("database").mkdir()
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(moonraker_utils, "get_instances", lambda model: [])
backup_moonraker_db_dir()
assert len(calls) == 1
class TestLoadSysdepsJson:
def test_loads_valid_json(self, tmp_path: Path) -> None:
file = tmp_path / "deps.json"
file.write_text('{"debian": ["curl"]}')
result = load_sysdeps_json(file)
assert result == {"debian": ["curl"]}
def test_returns_empty_on_invalid_json(self, tmp_path: Path) -> None:
file = tmp_path / "deps.json"
file.write_text("not json")
result = load_sysdeps_json(file)
assert result == {}
-11
View File
@@ -8,16 +8,5 @@
# ======================================================================= #
from pathlib import Path
from typing import Callable, Dict
from components.webui_client.base_data import BaseWebClient
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
MODULE_PATH = Path(__file__).resolve().parent
# Shared registry of supported web clients
CLIENTS: Dict[str, Callable[[], BaseWebClient]] = {
"mainsail": MainsailData,
"fluidd": FluiddData,
}
@@ -0,0 +1,98 @@
# ======================================================================= #
# 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 typing import List, Optional
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client.base_data import BaseWebClientConfig
from core.logger import Logger
from core.services.backup_service import BackupService
from core.services.message_service import Message
from core.types.color import Color
from utils.config_utils import remove_config_section
from utils.fs_utils import run_remove_routines
from utils.instance_type import InstanceType
from utils.instance_utils import get_instances
def run_client_config_removal(
client_config: BaseWebClientConfig,
kl_instances: List[Klipper],
mr_instances: List[Moonraker],
svc: Optional[BackupService] = None,
) -> Message:
completion_msg = Message(
title=f"{client_config.display_name} Removal Process completed",
color=Color.GREEN,
)
Logger.print_status(f"Removing {client_config.display_name} ...")
if run_remove_routines(client_config.config_dir):
completion_msg.text.append(f"{client_config.display_name} removed")
if svc is None:
svc = BackupService()
svc.backup_moonraker_conf()
completion_msg = remove_moonraker_config_section(
completion_msg, client_config, mr_instances
)
svc.backup_printer_cfg()
completion_msg = remove_printer_config_section(
completion_msg, client_config, kl_instances
)
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
return completion_msg
def remove_cfg_symlink(client_config: BaseWebClientConfig, message: Message) -> Message:
instances: List[Klipper] = get_instances(Klipper)
kl_instances = []
for instance in instances:
cfg = instance.base.cfg_dir.joinpath(client_config.config_filename)
if run_remove_routines(cfg):
kl_instances.append(instance)
text = f"{client_config.display_name} removed from instance"
return update_msg(kl_instances, message, text)
def remove_printer_config_section(
message: Message, client_config: BaseWebClientConfig, kl_instances: List[Klipper]
) -> Message:
kl_section = client_config.config_section
kl_instances = remove_config_section(kl_section, kl_instances)
text = f"Klipper config section '{kl_section}' removed for instance"
return update_msg(kl_instances, message, text)
def remove_moonraker_config_section(
message: Message, client_config: BaseWebClientConfig, mr_instances: List[Moonraker]
) -> Message:
mr_section = f"update_manager {client_config.name}"
mr_instances = remove_config_section(mr_section, mr_instances)
text = f"Moonraker config section '{mr_section}' removed for instance"
return update_msg(mr_instances, message, text)
def update_msg(instances: List[InstanceType], message: Message, text: str) -> Message:
if not instances:
return message
instance_names = [i.service_file_path.stem for i in instances]
message.text.append(f"{text}: {', '.join(instance_names)}")
return message
@@ -0,0 +1,126 @@
# ======================================================================= #
# 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 shutil
import subprocess
from pathlib import Path
from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client.base_data import BaseWebClient, BaseWebClientConfig
from components.webui_client.client_dialogs import (
print_client_already_installed_dialog,
)
from components.webui_client.client_utils import (
backup_client_config_data,
detect_client_cfg_conflict,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.services.backup_service import BackupService
from core.settings.kiauh_settings import KiauhSettings
from utils.config_utils import add_config_section, add_config_section_at_top
from utils.fs_utils import create_symlink
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import get_confirm
from utils.instance_utils import get_instances
def install_client_config(client_data: BaseWebClient, cfg_backup=True) -> None:
client_config: BaseWebClientConfig = client_data.client_config
display_name = client_config.display_name
if detect_client_cfg_conflict(client_data):
Logger.print_info("Another Client-Config is already installed! Skipped ...")
return
if client_config.config_dir.exists():
print_client_already_installed_dialog(display_name)
if get_confirm(f"Re-install {display_name}?", allow_go_back=True):
shutil.rmtree(client_config.config_dir)
else:
return
mr_instances: List[Moonraker] = get_instances(Moonraker)
kl_instances = get_instances(Klipper)
try:
download_client_config(client_config)
create_client_config_symlink(client_config, kl_instances)
if cfg_backup:
BackupService().backup_printer_config_dir()
add_config_section(
section=f"update_manager {client_config.name}",
instances=mr_instances,
options=[
("type", "git_repo"),
("primary_branch", "master"),
("path", str(client_config.config_dir)),
("origin", str(client_config.repo_url)),
("managed_services", "klipper"),
],
)
add_config_section_at_top(client_config.config_section, kl_instances)
InstanceManager.restart_all(kl_instances)
except Exception as e:
Logger.print_error(f"{display_name} installation failed!\n{e}")
return
Logger.print_ok(f"{display_name} installation complete!", start="\n")
def download_client_config(client_config: BaseWebClientConfig) -> None:
try:
Logger.print_status(f"Downloading {client_config.display_name} ...")
repo = client_config.repo_url
target_dir = client_config.config_dir
git_clone_wrapper(repo, target_dir)
except Exception:
Logger.print_error(f"Downloading {client_config.display_name} failed!")
raise
def update_client_config(client: BaseWebClient) -> None:
client_config: BaseWebClientConfig = client.client_config
Logger.print_status(f"Updating {client_config.display_name} ...")
if not client_config.config_dir.exists():
Logger.print_info(
f"Unable to update {client_config.display_name}. Directory does not exist! Skipping ..."
)
return
settings = KiauhSettings()
if settings.kiauh.backup_before_update:
backup_client_config_data(client)
git_pull_wrapper(client_config.config_dir)
Logger.print_ok(f"Successfully updated {client_config.display_name}.")
Logger.print_info("Restart Klipper to reload the configuration!")
def create_client_config_symlink(
client_config: BaseWebClientConfig, klipper_instances: List[Klipper]
) -> None:
for instance in klipper_instances:
Logger.print_status(f"Create symlink for {client_config.config_filename} ...")
source = Path(client_config.config_dir, client_config.config_filename)
target = instance.base.cfg_dir
Logger.print_status(f"Linking {source} to {target}")
try:
create_symlink(source, target)
except subprocess.CalledProcessError:
Logger.print_error("Creating symlink failed!")
@@ -0,0 +1,125 @@
# ======================================================================= #
# 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 typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client.base_data import (
BaseWebClient,
)
from components.webui_client.client_config.client_config_remove import (
run_client_config_removal,
)
from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED
from core.logger import Logger
from core.services.backup_service import BackupService
from core.services.message_service import Message
from core.types.color import Color
from utils.config_utils import remove_config_section
from utils.fs_utils import (
remove_with_sudo,
run_remove_routines,
)
from utils.instance_utils import get_instances
def run_client_removal(
client: BaseWebClient,
remove_client: bool,
remove_client_cfg: bool,
backup_config: bool,
) -> Message:
completion_msg = Message(
title=f"{client.display_name} Removal Process completed",
color=Color.GREEN,
)
mr_instances: List[Moonraker] = get_instances(Moonraker)
kl_instances: List[Klipper] = get_instances(Klipper)
svc = BackupService()
if backup_config:
version = ""
src = client.client_dir
if src.joinpath(".version").exists():
with open(src.joinpath(".version"), "r") as v:
version = v.readlines()[0]
target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}")
success = svc.backup_file(
source_path=client.config_file,
target_path=target_path,
)
if success:
completion_msg.text.append(f"{client.config_file.name} backup created")
if remove_client:
client_name = client.name
if remove_client_dir(client):
completion_msg.text.append(f"{client.display_name} removed")
if remove_client_nginx_config(client_name):
completion_msg.text.append("● NGINX config removed")
if remove_client_nginx_logs(client, kl_instances):
completion_msg.text.append("● NGINX logs removed")
svc.backup_moonraker_conf()
section = f"update_manager {client_name}"
handled_instances: List[Moonraker] = remove_config_section(
section, mr_instances
)
if handled_instances:
names = [i.service_file_path.stem for i in handled_instances]
completion_msg.text.append(
f"● Moonraker config section '{section}' removed for instance: {', '.join(names)}"
)
if remove_client_cfg:
cfg_completion_msg = run_client_config_removal(
client.client_config,
kl_instances,
mr_instances,
svc,
)
if cfg_completion_msg.color == Color.GREEN:
completion_msg.text.extend(cfg_completion_msg.text[1:])
if not completion_msg.text:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text.append("Nothing to remove.")
else:
completion_msg.text.insert(0, "The following actions were performed:")
return completion_msg
def remove_client_dir(client: BaseWebClient) -> bool:
Logger.print_status(f"Removing {client.display_name} ...")
return run_remove_routines(client.client_dir)
def remove_client_nginx_config(name: str) -> bool:
Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...")
return remove_with_sudo(
[
NGINX_SITES_AVAILABLE.joinpath(name),
NGINX_SITES_ENABLED.joinpath(name),
]
)
def remove_client_nginx_logs(client: BaseWebClient, instances: List[Klipper]) -> bool:
Logger.print_status(f"Removing NGINX logs for {client.display_name} ...")
files = [client.nginx_access_log, client.nginx_error_log]
if instances:
for instance in instances:
files.append(instance.base.log_dir.joinpath(client.nginx_access_log.name))
files.append(instance.base.log_dir.joinpath(client.nginx_error_log.name))
return remove_with_sudo(files)
@@ -0,0 +1,188 @@
# ======================================================================= #
# 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 #
# ======================================================================= #
import shutil
import tempfile
from pathlib import Path
from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client import MODULE_PATH
from components.webui_client.base_data import (
BaseWebClient,
BaseWebClientConfig,
WebClientType,
)
from components.webui_client.client_config.client_config_setup import (
install_client_config,
)
from components.webui_client.client_dialogs import (
print_install_client_config_dialog,
print_moonraker_not_found_dialog,
)
from components.webui_client.client_utils import (
copy_common_vars_nginx_cfg,
copy_upstream_nginx_cfg,
create_nginx_cfg,
detect_client_cfg_conflict,
enable_mainsail_remotemode,
get_client_port_selection,
symlink_webui_nginx_log,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.services.backup_service import BackupService
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
from utils.common import check_install_dependencies
from utils.config_utils import add_config_section
from utils.fs_utils import unzip
from utils.input_utils import get_confirm
from utils.instance_utils import get_instances
from utils.sys_utils import (
cmd_sysctl_service,
download_file,
get_ipv4_addr,
)
def install_client(
client: BaseWebClient,
settings: KiauhSettings,
reinstall: bool = False,
) -> None:
mr_instances: List[Moonraker] = get_instances(Moonraker)
enable_remotemode = False
if not mr_instances:
print_moonraker_not_found_dialog(client.display_name)
if not get_confirm(f"Continue {client.display_name} installation?"):
return
# if moonraker is not installed or multiple instances
# are installed we enable mainsails remote mode
if (
client.client == WebClientType.MAINSAIL
and not mr_instances
or len(mr_instances) > 1
):
enable_remotemode = True
kl_instances = get_instances(Klipper)
install_client_cfg = False
client_config: BaseWebClientConfig = client.client_config
if (
kl_instances
and not client_config.config_dir.exists()
and not detect_client_cfg_conflict(client)
):
print_install_client_config_dialog(client)
question = f"Download the recommended {client_config.display_name}?"
install_client_cfg = get_confirm(question, allow_go_back=False)
default_port: int = int(settings.get(client.name, "port"))
port: int = (
default_port if reinstall else get_client_port_selection(client, settings)
)
check_install_dependencies({"nginx"})
try:
download_client(client)
if enable_remotemode and client.client == WebClientType.MAINSAIL:
enable_mainsail_remotemode()
BackupService().backup_printer_config_dir()
add_config_section(
section=f"update_manager {client.name}",
instances=mr_instances,
options=[
("persistent_files", ["config.json"]),
("type", "web"),
("channel", "stable"),
("repo", str(client.repo_path)),
("path", str(client.client_dir)),
],
)
InstanceManager.restart_all(mr_instances)
if install_client_cfg and kl_instances:
install_client_config(client, False)
copy_upstream_nginx_cfg()
copy_common_vars_nginx_cfg()
create_nginx_cfg(
display_name=client.display_name,
cfg_name=client.name,
template_src=MODULE_PATH.joinpath("assets/nginx_cfg"),
PORT=port,
ROOT_DIR=client.client_dir,
NAME=client.name,
)
if kl_instances:
symlink_webui_nginx_log(client, kl_instances)
cmd_sysctl_service("nginx", "restart")
except Exception as e:
Logger.print_error(e)
Logger.print_dialog(
DialogType.ERROR,
center_content=True,
content=[f"{client.display_name} installation failed!"],
)
return
# noinspection HttpUrlsUsage
Logger.print_dialog(
DialogType.CUSTOM,
custom_title=f"{client.display_name} installation complete!",
custom_color=Color.GREEN,
center_content=True,
content=[
f"Open {client.display_name} now on: http://{get_ipv4_addr()}{'' if port == 80 else f':{port}'}",
],
)
def download_client(client: BaseWebClient) -> None:
zipfile = f"{client.name.lower()}.zip"
target = Path().home().joinpath(zipfile)
try:
Logger.print_status(
f"Downloading {client.display_name} from {client.download_url} ..."
)
download_file(client.download_url, target, True)
Logger.print_ok("Download complete!")
Logger.print_status(f"Extracting {zipfile} ...")
unzip(target, client.client_dir)
target.unlink(missing_ok=True)
Logger.print_ok("OK!")
except Exception:
Logger.print_error(f"Downloading {client.display_name} failed!")
raise
def update_client(client: BaseWebClient) -> None:
Logger.print_status(f"Updating {client.display_name} ...")
if not client.client_dir.exists():
Logger.print_info(
f"Unable to update {client.display_name}. Directory does not exist! Skipping ..."
)
return
with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file:
Logger.print_status(
f"Creating temporary backup of {client.config_file} as {tmp_file.name} ..."
)
shutil.copy(client.config_file, tmp_file.name)
download_client(client)
shutil.copy(tmp_file.name, client.config_file)
@@ -20,7 +20,6 @@ from components.klipper.klipper import Klipper
from components.webui_client import MODULE_PATH
from components.webui_client.base_data import (
BaseWebClient,
BaseWebClientConfig,
WebClientType,
)
from components.webui_client.client_dialogs import print_client_port_select_dialog
@@ -483,18 +482,3 @@ def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> Non
with open(config, "w") as f:
f.writelines(lines)
def create_client_config_symlink(
client_config: BaseWebClientConfig, klipper_instances: List[Klipper]
) -> None:
"""Symlink the client config file into every Klipper instance's config dir."""
for instance in klipper_instances:
Logger.print_status(f"Create symlink for {client_config.config_filename} ...")
source = Path(client_config.config_dir, client_config.config_filename)
target = instance.base.cfg_dir
Logger.print_status(f"Linking {source} to {target}")
try:
create_symlink(source, target)
except Exception:
Logger.print_error("Creating symlink failed!")
@@ -12,14 +12,12 @@ import textwrap
from typing import Type
from components.webui_client.base_data import BaseWebClient
from components.webui_client.client_setup import install_client
from components.webui_client.client_utils import (
get_client_port_selection,
get_nginx_listen_port,
set_listen_port,
)
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
from core.logger import Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
@@ -67,9 +65,7 @@ class ClientInstallMenu(BaseMenu):
print(menu, end="")
def reinstall_client(self, **kwargs) -> None:
WebClientSetupService(self.client.name).install(
reinstall=True, interactive=True
)
install_client(self.client, settings=self.settings, reinstall=True)
def change_listen_port(self, **kwargs) -> None:
curr_port = self._get_current_port()
@@ -11,10 +11,8 @@ from __future__ import annotations
import textwrap
from typing import Type
from components.webui_client import client_remove
from components.webui_client.base_data import BaseWebClient
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.types.color import Color
@@ -102,12 +100,13 @@ class ClientRemoveMenu(BaseMenu):
print(Color.apply("Nothing selected ...", Color.RED))
return
WebClientSetupService(self.client.name).remove(
completion_msg = client_remove.run_client_removal(
client=self.client,
remove_client=self.remove_client,
remove_client_cfg=self.remove_client_cfg,
backup_config=self.backup_config_json,
interactive=True,
)
self.message_service.set_message(completion_msg)
self.remove_client = False
self.remove_client_cfg = False
@@ -1,105 +0,0 @@
# ======================================================================= #
# 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
from pathlib import Path
from typing import Any, List
import components.webui_client.menus.client_install_menu as cim_module
import pytest
from components.webui_client.menus.client_install_menu import ClientInstallMenu
class _FakeClient:
def __init__(self, name: str = "mainsail") -> None:
self.name = name
self.display_name = name.capitalize()
self.nginx_config = Path("/tmp/nginx/mainsail")
def _build_menu(monkeypatch: pytest.MonkeyPatch, current_port: int | None = 80) -> ClientInstallMenu:
# Neutralise singletons / IO from BaseMenu and KiauhSettings.
monkeypatch.setattr(cim_module, "KiauhSettings", lambda: _FakeSettings())
monkeypatch.setattr(cim_module, "get_nginx_listen_port", lambda cfg: current_port)
return ClientInstallMenu(_FakeClient())
class _FakeSettings:
def __init__(self) -> None:
self._section = _FakeSection()
self.mainsail = self._section
self.fluidd = self._section
def save(self) -> None:
self._section.saved = True
def __getitem__(self, key):
return self._section
class _FakeSection:
port = 80
saved = False
class TestClientInstallMenu:
def test_options_cover_reinstall_and_port_change(self, monkeypatch) -> None:
menu = _build_menu(monkeypatch)
# BaseMenu may append a "back" footer option depending on the menu's
# footer type; the two install-specific entries must always be present.
assert {"1", "2"}.issubset(menu.options.keys())
def test_set_previous_menu_defaults_to_install_menu(self, monkeypatch) -> None:
menu = _build_menu(monkeypatch)
menu.set_previous_menu(None)
from core.menus.install_menu import InstallMenu
assert menu.previous_menu is InstallMenu
def test_get_current_port_uses_nginx_value(self, monkeypatch) -> None:
menu = _build_menu(monkeypatch, current_port=8080)
assert menu._get_current_port() == 8080
def test_get_current_port_falls_back_to_settings(self, monkeypatch) -> None:
menu = _build_menu(monkeypatch, current_port=None)
# FakeSettings._FakeSection.port == 80
assert menu._get_current_port() == 80
def test_reinstall_delegates_to_web_client_setup_service(self, monkeypatch) -> None:
menu = _build_menu(monkeypatch)
calls: List[Any] = []
class _FakeService:
def install(self, **kwargs) -> bool:
calls.append(kwargs)
return True
monkeypatch.setattr(cim_module, "WebClientSetupService", lambda name: _FakeService())
menu.reinstall_client()
assert calls
assert calls[0]["reinstall"] is True
assert calls[0]["interactive"] is True
def test_change_listen_port_persists_and_restarts_nginx(self, monkeypatch, tmp_path) -> None:
menu = _build_menu(monkeypatch)
captured: dict = {}
monkeypatch.setattr(cim_module, "get_client_port_selection", lambda *a, **k: 9090)
monkeypatch.setattr(cim_module, "cmd_sysctl_service", lambda svc, action: captured.setdefault("nginx", []).append(action))
monkeypatch.setattr(cim_module, "set_listen_port", lambda client, c, n: captured.setdefault("set_port", (c, n)))
monkeypatch.setattr(cim_module, "get_ipv4_addr", lambda: "127.0.0.1")
# Inject a fake message service to avoid the real MessageService.
menu.message_service = type("MS", (), {"set_message": lambda self, m: captured.setdefault("msg", m)})()
menu.change_listen_port()
assert captured["nginx"] == ["stop", "start"]
assert captured["set_port"] == (80, 9090)
assert menu.client_settings.port == 9090
@@ -1,63 +0,0 @@
# ======================================================================= #
# 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
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from components.webui_client.base_data import WebClientType
@dataclass
class FakeClientConfig:
name: str = "mainsail-config"
display_name: str = "Mainsail-Config"
config_filename: str = "mainsail.cfg"
config_section: str = "include mainsail.cfg"
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
config_dir: Path = Path("/tmp/mainsail-config")
@dataclass
class FakeWebClient:
name: str = "mainsail"
display_name: str = "Mainsail"
client: WebClientType = WebClientType.MAINSAIL
client_dir: Path = Path("/tmp/mainsail")
config_file: Path = Path("/tmp/mainsail/config.json")
repo_path: str = "mainsail-crew/mainsail"
nginx_config: Path = Path("/tmp/nginx/mainsail")
nginx_access_log: Path = Path("/tmp/log/mainsail-access.log")
nginx_error_log: Path = Path("/tmp/log/mainsail-error.log")
download_url: str = "https://example.com/mainsail.zip"
client_config: Any = field(default_factory=FakeClientConfig)
@pytest.fixture
def client() -> FakeWebClient:
return FakeWebClient()
@pytest.fixture
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
from core.settings.kiauh_settings import KiauhSettings
KiauhSettings._KiauhSettings__instance = None
KiauhSettings._KiauhSettings__initialized = False
@pytest.fixture
def settings(reset_settings) -> Any:
from core.settings.kiauh_settings import KiauhSettings
return KiauhSettings()
@@ -1,415 +0,0 @@
# ======================================================================= #
# 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
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.webui_client.services.web_client_config_setup_service import (
WebClientConfigSetupService,
)
@pytest.fixture
def bind_client(client, monkeypatch: pytest.MonkeyPatch):
"""Make the service construct the per-test FakeWebClient instead of the real data class."""
monkeypatch.setattr(
WebClientConfigSetupService,
"CLIENTS",
{"mainsail": lambda: client, "fluidd": lambda: client},
)
return client
@pytest.fixture
def patched_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]:
calls: Dict[str, List[Any]] = {
"download": [],
"symlink": [],
"backup_printer": [],
"add_section": [],
"add_section_at_top": [],
"restart": [],
}
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: False)
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.git_clone_wrapper",
lambda repo, target: calls["download"].append((repo, str(target))),
)
monkeypatch.setattr(
f"{module}.create_client_config_symlink",
lambda cfg, kl: calls["symlink"].append((cfg.name, kl)),
)
class FakeBackup:
def backup_printer_config_dir(self) -> None:
calls["backup_printer"].append(True)
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.add_config_section",
lambda **kwargs: calls["add_section"].append(kwargs["section"]),
)
monkeypatch.setattr(
f"{module}.add_config_section_at_top",
lambda section, instances: calls["add_section_at_top"].append(section),
)
monkeypatch.setattr(
f"{module}.InstanceManager.restart_all",
staticmethod(lambda instances: calls["restart"].append(len(instances))),
)
return calls
class TestWebClientConfigSetupServiceConstruction:
def test_accepts_known_clients(self) -> None:
for name in ("mainsail", "fluidd"):
svc = WebClientConfigSetupService(name)
assert svc.name == name
def test_rejects_unknown_client(self) -> None:
with pytest.raises(ValueError):
WebClientConfigSetupService("unknown")
def test_clients_mapping_is_the_single_shared_source(self) -> None:
# there must be exactly one CLIENTS dict, imported from
# components.webui_client by both web-client services.
from components import webui_client
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
assert webui_client.CLIENTS is WebClientConfigSetupService.CLIENTS
assert webui_client.CLIENTS is WebClientSetupService.CLIENTS
assert set(WebClientConfigSetupService.CLIENTS.keys()) == {"mainsail", "fluidd"}
class TestInstallClientConfig:
def test_installs_when_clean(
self, bind_client, patched_install_deps, tmp_path: Path
) -> None:
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
result = WebClientConfigSetupService("mainsail").install()
assert result is True
assert patched_install_deps["download"]
assert patched_install_deps["symlink"]
assert "update_manager mainsail-config" in patched_install_deps["add_section"]
def test_skips_when_conflict_detected(
self, bind_client, patched_install_deps, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: True)
result = WebClientConfigSetupService("mainsail").install()
assert result is True
assert patched_install_deps["download"] == []
def test_interactive_reinstall_after_confirm(
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
removed: List[Path] = []
monkeypatch.setattr(f"{module}.shutil.rmtree", lambda p: removed.append(p))
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
result = WebClientConfigSetupService("mainsail").install(interactive=True)
assert result is True
assert removed == [bind_client.client_config.config_dir]
assert patched_install_deps["download"]
def test_interactive_decline_reinstall_skips(
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
f"{module}.shutil.rmtree", lambda p: pytest.fail("no rmtree")
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
result = WebClientConfigSetupService("mainsail").install(interactive=True)
assert result is True
assert patched_install_deps["download"] == []
def test_non_interactive_existing_dir_skips(
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
f"{module}.get_confirm",
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
)
result = WebClientConfigSetupService("mainsail").install(interactive=False)
assert result is True
assert patched_install_deps["download"] == []
def test_install_failure_returns_false(
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
) -> None:
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(
f"{module}.git_clone_wrapper",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
result = WebClientConfigSetupService("mainsail").install()
assert result is False
class TestUpdateClientConfig:
def test_update_skips_when_dir_missing(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
module = "components.webui_client.services.web_client_config_setup_service"
pulled: List[Any] = []
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
)
result = WebClientConfigSetupService("mainsail").update()
assert result is True
assert pulled == []
def test_update_pulls_when_dir_exists(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
pulled: List[Any] = []
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
)
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
result = WebClientConfigSetupService("mainsail").update()
assert result is True
assert pulled == ["pull"]
def test_update_failure_returns_false(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
f"{module}.git_pull_wrapper",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
result = WebClientConfigSetupService("mainsail").update()
assert result is False
def test_update_non_interactive_omits_restart_hint(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
printed: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg))
)
WebClientConfigSetupService("mainsail").update(interactive=False)
assert not any("Restart Klipper" in m for m in printed)
def test_update_interactive_shows_restart_hint(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
printed: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg))
)
WebClientConfigSetupService("mainsail").update(interactive=True)
assert any("Restart Klipper" in m for m in printed)
class TestRemoveClientConfig:
def test_remove_signature_rejects_unused_interactive_parameter(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: True)
monkeypatch.setattr(f"{module}.remove_config_section", lambda s, i: i)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_printer_cfg(self) -> None:
pass
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
)
with pytest.raises(TypeError):
WebClientConfigSetupService("mainsail").remove(interactive=True)
def test_remove_runs_dir_and_section_cleanup(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
removed: List[str] = []
sections: List[str] = []
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda p: removed.append(str(p)) or True,
)
monkeypatch.setattr(
f"{module}.remove_config_section",
lambda section, instances: sections.append(section) or instances,
)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_printer_cfg(self) -> None:
pass
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
)
result = WebClientConfigSetupService("mainsail").remove()
assert result is True
assert any("mainsail-config" in p for p in removed)
def test_remove_failure_returns_false(self, bind_client, monkeypatch) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda p: (_ for _ in ()).throw(RuntimeError("boom")),
)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_printer_cfg(self) -> None:
pass
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
)
result = WebClientConfigSetupService("mainsail").remove()
assert result is False
class TestRemoveConfig:
"""The config-removal operation mutates the filesystem and returns the
completion message. Its name must reflect that it does the removal, not
merely build a message."""
def test_old_build_removal_message_name_no_longer_exists(self) -> None:
assert not hasattr(WebClientConfigSetupService, "build_removal_message")
def test_remove_config_performs_destructive_removal_and_returns_message(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
removed: List[str] = []
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda p: removed.append(str(p)) or True,
)
monkeypatch.setattr(
f"{module}.remove_config_section",
lambda section, instances: instances,
)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_printer_cfg(self) -> None:
pass
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
message = WebClientConfigSetupService("mainsail").remove_config(
kl_instances=[], mr_instances=[], backup_config=False
)
assert removed # destructive removal actually ran
assert message.text # completion message populated
assert any("config" in line.lower() for line in message.text)
def test_remove_config_nothing_to_remove_message(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_config_setup_service"
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: False)
monkeypatch.setattr(
f"{module}.remove_config_section",
lambda section, instances: instances,
)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_printer_cfg(self) -> None:
pass
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
message = WebClientConfigSetupService("mainsail").remove_config(
kl_instances=[], mr_instances=[]
)
assert "Nothing to remove." in message.text
@@ -1,530 +0,0 @@
# ======================================================================= #
# 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
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.webui_client.base_data import WebClientType
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
@pytest.fixture
def bind_client(client, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
WebClientSetupService, "CLIENTS", {"mainsail": lambda: client, "fluidd": lambda: client}
)
return client
class FakeInstance:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.service_file_path = Path(f"service-{suffix}.service")
self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})()
@pytest.fixture
def patch_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]:
calls: Dict[str, List[Any]] = {
"download_client": [],
"enable_remotemode": [],
"backup_printer": [],
"add_config_section": [],
"restart_all": [],
"install_client_config": [],
"copy_upstream": [],
"copy_common_vars": [],
"create_nginx_cfg": [],
"symlink_logs": [],
"restart_nginx": [],
}
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(f"{module}.check_install_dependencies", lambda packages: None)
monkeypatch.setattr(
f"{module}._download_client",
lambda client: calls["download_client"].append(client.name),
)
monkeypatch.setattr(
f"{module}.enable_mainsail_remotemode",
lambda: calls["enable_remotemode"].append(True),
)
class FakeBackup:
def backup_printer_config_dir(self) -> None:
calls["backup_printer"].append(True)
def backup_moonraker_conf(self) -> None:
calls["backup_printer"].append("moonraker_conf")
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.add_config_section",
lambda **kwargs: calls["add_config_section"].append(kwargs),
)
monkeypatch.setattr(
f"{module}.InstanceManager.restart_all",
staticmethod(lambda instances: calls["restart_all"].append(len(instances))),
)
monkeypatch.setattr(
f"{module}.WebClientConfigSetupService",
lambda name: type(
"FakeCfgSvc",
(),
{
"install": lambda self, cfg_backup=True, interactive=True: (
calls["install_client_config"].append((name, cfg_backup, interactive))
or True
)
},
)(),
)
monkeypatch.setattr(
f"{module}.copy_upstream_nginx_cfg", lambda: calls["copy_upstream"].append(True)
)
monkeypatch.setattr(
f"{module}.copy_common_vars_nginx_cfg",
lambda: calls["copy_common_vars"].append(True),
)
monkeypatch.setattr(
f"{module}.create_nginx_cfg",
lambda **kwargs: calls["create_nginx_cfg"].append(kwargs),
)
monkeypatch.setattr(
f"{module}.symlink_webui_nginx_log",
lambda client, instances: calls["symlink_logs"].append(
(client.name, len(instances))
),
)
monkeypatch.setattr(
f"{module}.cmd_sysctl_service",
lambda service, action: calls["restart_nginx"].append((service, action)),
)
return calls
class TestWebClientSetupServiceConstruction:
@pytest.mark.parametrize("name", ["mainsail", "fluidd"])
def test_accepts_known_clients(self, name: str) -> None:
svc = WebClientSetupService(name)
assert svc.name == name
def test_rejects_unknown_client(self) -> None:
with pytest.raises(ValueError):
WebClientSetupService("unknown")
class TestInstallClient:
def test_interactive_install_runs_all_steps(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
lambda c, s, reconfigure=False: 80,
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_confirm",
lambda *a, **k: True,
)
result = WebClientSetupService("mainsail").install()
assert result is True
assert patch_install_deps["download_client"] == ["mainsail"]
assert patch_install_deps["create_nginx_cfg"]
assert patch_install_deps["restart_nginx"] == [("nginx", "restart")]
def test_headless_install_uses_explicit_port_and_cfg(
self, bind_client, patch_install_deps, monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_confirm",
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
lambda *a, **k: pytest.fail("should not select port interactively"),
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_instances",
lambda model: [FakeInstance()] if model.__name__ == "Klipper" else [],
)
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
result = WebClientSetupService("mainsail").install(
interactive=False, port=8080, install_client_cfg=True, continue_without_moonraker=True
)
assert result is True
assert patch_install_deps["download_client"] == ["mainsail"]
assert patch_install_deps["install_client_config"] == [("mainsail", False, False)]
nginx_call = patch_install_deps["create_nginx_cfg"][0]
assert nginx_call["PORT"] == 8080
def test_reinstall_uses_default_port_without_prompting(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_instances",
lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [],
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
lambda *a, **k: pytest.fail("should not prompt for port during reinstall"),
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_confirm",
lambda *a, **k: pytest.fail("should not prompt during reinstall"),
)
result = WebClientSetupService("mainsail").install(reinstall=True, interactive=True)
assert result is True
nginx_call = patch_install_deps["create_nginx_cfg"][0]
assert nginx_call["PORT"] == 80
def test_reinstall_explicit_port_overrides_default(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_instances",
lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [],
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
lambda *a, **k: pytest.fail("should not prompt when port is explicit"),
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.get_confirm",
lambda *a, **k: pytest.fail("should not prompt during reinstall"),
)
result = WebClientSetupService("mainsail").install(
reinstall=True, interactive=True, port=9090
)
assert result is True
nginx_call = patch_install_deps["create_nginx_cfg"][0]
assert nginx_call["PORT"] == 9090
def test_headless_install_without_moonraker_returns_false(
self, bind_client, patch_install_deps
) -> None:
result = WebClientSetupService("mainsail").install(
interactive=False, continue_without_moonraker=False
)
assert result is False
assert patch_install_deps["download_client"] == []
def test_install_failure_returns_false(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service._download_client",
lambda client: (_ for _ in ()).throw(RuntimeError("boom")),
)
result = WebClientSetupService("mainsail").install(
interactive=False, continue_without_moonraker=True
)
assert result is False
def test_headless_install_failure_does_not_show_error_dialog(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(
f"{module}._download_client",
lambda client: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: pytest.fail("should not show error dialog in headless mode"),
)
result = WebClientSetupService("mainsail").install(
interactive=False, continue_without_moonraker=True
)
assert result is False
def test_headless_install_does_not_show_completion_dialog(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(
f"{module}.get_confirm",
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
)
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: pytest.fail("should not show dialog in headless mode"),
)
result = WebClientSetupService("mainsail").install(
interactive=False, continue_without_moonraker=True
)
assert result is True
def test_interactive_install_shows_completion_dialog(
self, bind_client, patch_install_deps, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(
f"{module}.get_confirm", lambda *a, **k: True
)
monkeypatch.setattr(
f"{module}.get_client_port_selection",
lambda c, s, reconfigure=False: 80,
)
dialog_calls: List[Any] = []
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: dialog_calls.append(k),
)
WebClientSetupService("mainsail").install(interactive=True)
assert dialog_calls
class TestUpdateClient:
def test_update_downloads_and_restores_config(
self, bind_client, monkeypatch
) -> None:
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service._download_client",
lambda c: None,
)
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service.shutil.copy",
lambda src, dst: None,
)
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
bind_client.config_file.write_text("{}")
result = WebClientSetupService("mainsail").update()
assert result is True
def test_update_missing_dir_returns_true(
self, bind_client, monkeypatch, tmp_path: Path
) -> None:
bind_client.client_dir = tmp_path / "does-not-exist"
pulled: List[Any] = []
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service._download_client",
lambda c: pulled.append("download"),
)
result = WebClientSetupService("mainsail").update()
assert result is True
assert pulled == []
def test_update_failure_returns_false(
self, bind_client, monkeypatch
) -> None:
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
bind_client.config_file.write_text("{}")
monkeypatch.setattr(
"components.webui_client.services.web_client_setup_service._download_client",
lambda c: (_ for _ in ()).throw(RuntimeError("boom")),
)
result = WebClientSetupService("mainsail").update()
assert result is False
def test_update_accepts_interactive_parameter(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
bind_client.config_file.write_text("{}")
monkeypatch.setattr(f"{module}._download_client", lambda c: None)
monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None)
result = WebClientSetupService("mainsail").update(interactive=False)
assert result is True
def test_update_headless_does_not_show_dialog(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
bind_client.config_file.write_text("{}")
monkeypatch.setattr(f"{module}._download_client", lambda c: None)
monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None)
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: pytest.fail("should not show dialog in headless update"),
)
result = WebClientSetupService("mainsail").update(interactive=False)
assert result is True
class TestRemoveClientHelpers:
"""Directly exercise the small removal helper methods so the destructive
remove path is covered beyond the integrated ``remove()`` test."""
def test_remove_client_dir_returns_run_remove_result(self, bind_client, monkeypatch) -> None:
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(
f"{module}.run_remove_routines", lambda p: True
)
svc = WebClientSetupService("mainsail")
assert svc._remove_client_dir() is True
def test_remove_client_nginx_config_delegates_to_sudo(self, bind_client, monkeypatch) -> None:
module = "components.webui_client.services.web_client_setup_service"
removed: List[Any] = []
monkeypatch.setattr(
f"{module}.remove_with_sudo", lambda files: removed.append(files) or True
)
svc = WebClientSetupService("mainsail")
assert svc._remove_client_nginx_config("mainsail") is True
assert removed # files passed through
def test_remove_client_nginx_logs_appends_per_instance_paths(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
passed: List[Any] = []
monkeypatch.setattr(
f"{module}.remove_with_sudo", lambda files: passed.append(files) or True
)
class FakeKlipperInstance:
def __init__(self, suffix: str) -> None:
self.suffix = suffix
self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})()
svc = WebClientSetupService("mainsail")
result = svc._remove_client_nginx_logs(
svc.client, [FakeKlipperInstance("a"), FakeKlipperInstance("b")]
)
assert result is True
# 2 base log files + 2 per instance * 2 = 6 files total
assert len(passed[0]) == 6
class TestRemoteModeLogic:
@pytest.mark.parametrize(
"client_name, instance_count, expected",
[
("mainsail", 0, True),
("mainsail", 1, False),
("mainsail", 2, True),
("fluidd", 0, False),
("fluidd", 2, False),
],
)
def test_should_enable_remote_mode(
self, client_name: str, instance_count: int, expected: bool
) -> None:
svc = WebClientSetupService(client_name)
mr_instances = [FakeInstance(str(i)) for i in range(instance_count)]
result = svc._should_enable_remote_mode(mr_instances)
assert result is expected
def test_should_enable_remote_mode_rejects_non_mainsail(
self) -> None:
svc = WebClientSetupService("mainsail")
svc.client = type("NotMainsail", (), {"client": WebClientType.FLUIDD})()
assert svc._should_enable_remote_mode([]) is False
class TestRemoveClient:
def test_remove_client_and_config(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
removed_dir: List[str] = []
sections: List[str] = []
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda p: removed_dir.append(str(p)) or True,
)
monkeypatch.setattr(f"{module}.remove_with_sudo", lambda files: True)
class FakeBackup:
def backup_moonraker_conf(self) -> None:
pass
def backup_file(self, **kwargs) -> bool:
return True
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
monkeypatch.setattr(
f"{module}.remove_config_section",
lambda section, instances: sections.append(section) or instances,
)
build_called: List[Any] = []
monkeypatch.setattr(
f"{module}.WebClientConfigSetupService",
lambda name: type(
"FakeCfgSvc",
(),
{
"remove_config": lambda self, kl_instances, mr_instances, backup_config=True, svc=None: (
build_called.append(name) or type(
"Msg", (), {"color": 2, "text": ["x", "config removed"]}
)()
)
},
)(),
)
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
)
result = WebClientSetupService("mainsail").remove(
remove_client=True, remove_client_cfg=True, backup_config=False
)
assert result is True
assert build_called == ["mainsail"]
assert "update_manager mainsail" in sections
def test_remove_failure_returns_false(
self, bind_client, monkeypatch
) -> None:
module = "components.webui_client.services.web_client_setup_service"
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda p: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr(
f"{module}.MessageService",
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
)
result = WebClientSetupService("mainsail").remove(
remove_client=True, remove_client_cfg=False, backup_config=False
)
assert result is False
@@ -1,250 +0,0 @@
# ======================================================================= #
# 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 shutil
import traceback
from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client import CLIENTS
from components.webui_client.base_data import BaseWebClient, BaseWebClientConfig
from components.webui_client.client_dialogs import print_client_already_installed_dialog
from components.webui_client.client_utils import (
backup_client_config_data,
create_client_config_symlink,
detect_client_cfg_conflict,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.services.backup_service import BackupService
from core.services.message_service import Message, MessageService
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
from utils.config_utils import (
add_config_section,
add_config_section_at_top,
remove_config_section,
)
from utils.fs_utils import run_remove_routines
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import get_confirm
from utils.instance_utils import get_instances
class WebClientConfigSetupService:
"""Headless-capable service for installing, updating and removing web client configs."""
CLIENTS = CLIENTS
def __init__(self, name: str) -> None:
if name not in self.CLIENTS:
raise ValueError(f"Unknown web client: {name}")
self.name = name
self.client: BaseWebClient = self.CLIENTS[name]()
self.settings = KiauhSettings()
def install(
self,
cfg_backup: bool = True,
interactive: bool = True,
) -> bool:
"""Install the client config for this service's client.
Returns ``True`` on success or when the install is legitimately skipped
(conflict or already installed), and ``False`` when installation fails.
"""
client_config: BaseWebClientConfig = self.client.client_config
display_name = client_config.display_name
if detect_client_cfg_conflict(self.client):
Logger.print_info("Another Client-Config is already installed! Skipped ...")
return True
if client_config.config_dir.exists():
if interactive:
print_client_already_installed_dialog(display_name)
if get_confirm(f"Re-install {display_name}?", allow_go_back=True):
shutil.rmtree(client_config.config_dir)
else:
return True
else:
Logger.print_info(
f"{display_name} is already installed; "
"skipping non-interactive install."
)
return True
mr_instances: List[Moonraker] = get_instances(Moonraker)
kl_instances: List[Klipper] = get_instances(Klipper)
try:
self.__download_client_config(client_config)
create_client_config_symlink(client_config, kl_instances)
if cfg_backup:
BackupService().backup_printer_config_dir()
add_config_section(
section=f"update_manager {client_config.name}",
instances=mr_instances,
options=[
("type", "git_repo"),
("primary_branch", "master"),
("path", str(client_config.config_dir)),
("origin", str(client_config.repo_url)),
("managed_services", "klipper"),
],
)
add_config_section_at_top(client_config.config_section, kl_instances)
InstanceManager.restart_all(kl_instances)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error(f"{display_name} installation failed!")
return False
Logger.print_ok(f"{display_name} installation complete!", start="\n")
return True
def update(self, interactive: bool = True) -> bool:
"""Update the client config. Honors ``interactive`` to gate the
post-update "Restart Klipper" hint.
"""
client_config: BaseWebClientConfig = self.client.client_config
Logger.print_status(f"Updating {client_config.display_name} ...")
if not client_config.config_dir.exists():
Logger.print_info(
f"Unable to update {client_config.display_name}. "
"Directory does not exist! Skipping ..."
)
return True
if self.settings.kiauh.backup_before_update:
backup_client_config_data(self.client)
try:
git_pull_wrapper(client_config.config_dir)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error(f"Updating {client_config.display_name} failed!")
return False
Logger.print_ok(f"Successfully updated {client_config.display_name}.")
if interactive:
Logger.print_info("Restart Klipper to reload the configuration!")
return True
def remove(
self,
backup_config: bool = True,
) -> bool:
"""Remove the client config dir, symlinks and config sections.
Returns ``True`` on success and ``False`` if removal failed.
"""
client_config: BaseWebClientConfig = self.client.client_config
try:
message = self.remove_config(
kl_instances=get_instances(Klipper),
mr_instances=get_instances(Moonraker),
backup_config=backup_config,
)
MessageService().set_message(message)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error(f"Error while removing {client_config.display_name}!")
return False
return True
def remove_config(
self,
kl_instances: List[Klipper],
mr_instances: List[Moonraker],
backup_config: bool = True,
svc: BackupService | None = None,
) -> Message:
"""Remove the client config dir, its symlinks and config sections.
This method performs the actual (destructive) removal work and returns
the resulting completion ``Message``. It is named ``remove_config``
(not ``build_*``) so the call site obviously mutates the filesystem.
``WebClientSetupService.remove`` merges this message into the combined
client-removal message without double-setting it.
"""
client_config: BaseWebClientConfig = self.client.client_config
completion_msg = Message(
title=f"{client_config.display_name} Removal Process completed",
color=Color.GREEN,
)
Logger.print_status(f"Removing {client_config.display_name} ...")
if run_remove_routines(client_config.config_dir):
completion_msg.text.append(f"{client_config.display_name} removed")
if svc is None:
svc = BackupService()
svc.backup_moonraker_conf()
self.__remove_moonraker_config_section(
completion_msg, client_config, mr_instances
)
svc.backup_printer_cfg()
self.__remove_printer_config_section(
completion_msg, client_config, kl_instances
)
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
return completion_msg
def __download_client_config(self, client_config: BaseWebClientConfig) -> None:
Logger.print_status(f"Downloading {client_config.display_name} ...")
git_clone_wrapper(client_config.repo_url, client_config.config_dir)
@staticmethod
def __update_msg(instances: list, message: Message, text: str) -> Message:
if not instances:
return message
instance_names = [i.service_file_path.stem for i in instances]
message.text.append(f"{text}: {', '.join(instance_names)}")
return message
def __remove_printer_config_section(
self,
message: Message,
client_config: BaseWebClientConfig,
kl_instances: List[Klipper],
) -> None:
kl_section = client_config.config_section
handled = remove_config_section(kl_section, kl_instances)
self.__update_msg(
handled,
message,
f"Klipper config section '{kl_section}' removed for instance",
)
def __remove_moonraker_config_section(
self,
message: Message,
client_config: BaseWebClientConfig,
mr_instances: List[Moonraker],
) -> None:
mr_section = f"update_manager {client_config.name}"
handled = remove_config_section(mr_section, mr_instances)
self.__update_msg(
handled,
message,
f"Moonraker config section '{mr_section}' removed for instance",
)
@@ -1,365 +0,0 @@
# ======================================================================= #
# 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 shutil
import tempfile
import traceback
from pathlib import Path
from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client import CLIENTS, MODULE_PATH
from components.webui_client.base_data import BaseWebClient, WebClientType
from components.webui_client.client_dialogs import (
print_install_client_config_dialog,
print_moonraker_not_found_dialog,
)
from components.webui_client.client_utils import (
copy_common_vars_nginx_cfg,
copy_upstream_nginx_cfg,
create_nginx_cfg,
detect_client_cfg_conflict,
enable_mainsail_remotemode,
get_client_port_selection,
symlink_webui_nginx_log,
)
from components.webui_client.services.web_client_config_setup_service import (
WebClientConfigSetupService,
)
from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.services.backup_service import BackupService
from core.services.message_service import Message, MessageService
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
from utils.common import check_install_dependencies
from utils.config_utils import add_config_section, remove_config_section
from utils.fs_utils import remove_with_sudo, run_remove_routines, unzip
from utils.input_utils import get_confirm
from utils.instance_utils import get_instances
from utils.sys_utils import cmd_sysctl_service, download_file, get_ipv4_addr
class WebClientSetupService:
"""Headless-capable service for installing, updating and removing web clients."""
CLIENTS = CLIENTS
def __init__(self, name: str) -> None:
if name not in self.CLIENTS:
raise ValueError(f"Unknown web client: {name}")
self.name = name
self.client: BaseWebClient = self.CLIENTS[name]()
self.settings = KiauhSettings()
def install(
self,
reinstall: bool = False,
interactive: bool = True,
port: int | None = None,
install_client_cfg: bool | None = None,
continue_without_moonraker: bool = False,
) -> bool:
"""Install the web client.
When called from the TUI, choices are prompted interactively. The CLI
passes explicit values and ``interactive=False``.
Returns ``True`` on success and ``False`` when the installation could
not be completed.
"""
mr_instances: List[Moonraker] = get_instances(Moonraker)
enable_remotemode = False
if not mr_instances:
if interactive:
print_moonraker_not_found_dialog(self.client.display_name)
if not get_confirm(
f"Continue {self.client.display_name} installation?"
):
return False
elif not continue_without_moonraker:
Logger.print_info(
f"Moonraker not installed; skipping {self.client.display_name} installation."
)
return False
enable_remotemode = self._should_enable_remote_mode(mr_instances)
kl_instances: List[Klipper] = get_instances(Klipper)
install_cfg = False
client_config = self.client.client_config
if (
kl_instances
and not client_config.config_dir.exists()
and not detect_client_cfg_conflict(self.client)
):
if interactive:
print_install_client_config_dialog(self.client)
question = f"Download the recommended {client_config.display_name}?"
install_cfg = get_confirm(question, allow_go_back=False)
else:
install_cfg = bool(install_client_cfg)
default_port: int = int(self.settings.get(self.client.name, "port"))
if port is not None:
resolved_port = port
elif interactive and not reinstall:
resolved_port = get_client_port_selection(self.client, self.settings)
else:
resolved_port = default_port
check_install_dependencies({"nginx"})
try:
_download_client(self.client)
if enable_remotemode and self.client.client == WebClientType.MAINSAIL:
enable_mainsail_remotemode()
BackupService().backup_printer_config_dir()
add_config_section(
section=f"update_manager {self.client.name}",
instances=mr_instances,
options=[
("persistent_files", ["config.json"]),
("type", "web"),
("channel", "stable"),
("repo", str(self.client.repo_path)),
("path", str(self.client.client_dir)),
],
)
InstanceManager.restart_all(mr_instances)
if install_cfg and kl_instances:
WebClientConfigSetupService(self.name).install(
cfg_backup=False, interactive=interactive
)
copy_upstream_nginx_cfg()
copy_common_vars_nginx_cfg()
create_nginx_cfg(
display_name=self.client.display_name,
cfg_name=self.client.name,
template_src=MODULE_PATH.joinpath("assets/nginx_cfg"),
PORT=resolved_port,
ROOT_DIR=self.client.client_dir,
NAME=self.client.name,
)
if kl_instances:
symlink_webui_nginx_log(self.client, kl_instances)
cmd_sysctl_service("nginx", "restart")
except Exception:
Logger.print_error(traceback.format_exc())
if interactive:
Logger.print_dialog(
DialogType.ERROR,
center_content=True,
content=[f"{self.client.display_name} installation failed!"],
)
return False
webui_url: str = f"http://{get_ipv4_addr()}{'' if resolved_port == 80 else f':{resolved_port}'}"
if interactive:
Logger.print_dialog(
DialogType.CUSTOM,
custom_title=f"{self.client.display_name} installation complete!",
custom_color=Color.GREEN,
center_content=True,
content=[f"Open {self.client.display_name} now on: {webui_url}"],
)
else:
Logger.print_info(
f"Installation of {self.client.display_name} complete! URL: {webui_url}"
)
return True
def _should_enable_remote_mode(self, mr_instances: List[Moonraker]) -> bool:
"""Return whether Mainsail remote mode should be enabled.
Remote mode is required when Mainsail is installed without a local
Moonraker instance or when more than one Moonraker instance exists.
"""
return self.client.client == WebClientType.MAINSAIL and (
not mr_instances or len(mr_instances) > 1
)
def update(self, interactive: bool = True) -> bool:
"""Update the web client. Returns ``True`` on success, ``False`` on failure."""
Logger.print_status(f"Updating {self.client.display_name} ...")
if not self.client.client_dir.exists():
Logger.print_info(
f"Unable to update {self.client.display_name}. "
"Directory does not exist! Skipping ..."
)
return True
try:
with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file:
Logger.print_status(
f"Creating temporary backup of {self.client.config_file} "
f"as {tmp_file.name} ..."
)
shutil.copy(self.client.config_file, tmp_file.name)
_download_client(self.client)
shutil.copy(tmp_file.name, self.client.config_file)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error(f"Updating {self.client.display_name} failed!")
return False
return True
def remove(
self,
remove_client: bool = False,
remove_client_cfg: bool = False,
backup_config: bool = True,
interactive: bool = True,
) -> bool:
"""Remove the web client and (optionally) its config.
Returns ``True`` on success and ``False`` if removal failed.
"""
try:
message = self._build_removal_message(
remove_client=remove_client,
remove_client_cfg=remove_client_cfg,
backup_config=backup_config,
interactive=interactive,
)
MessageService().set_message(message)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error(f"Error while removing {self.client.display_name}!")
return False
return True
def _build_removal_message(
self,
remove_client: bool,
remove_client_cfg: bool,
backup_config: bool,
interactive: bool,
) -> Message:
completion_msg = Message(
title=f"{self.client.display_name} Removal Process completed",
color=Color.GREEN,
)
mr_instances: List[Moonraker] = get_instances(Moonraker)
kl_instances: List[Klipper] = get_instances(Klipper)
svc = BackupService()
if backup_config:
version = ""
src = self.client.client_dir
if src.joinpath(".version").exists():
with open(src.joinpath(".version"), "r") as v:
version = v.readlines()[0]
target_path = svc.backup_root.joinpath(
f"{self.client.client_dir.name}_{version}"
)
success = svc.backup_file(
source_path=self.client.config_file,
target_path=target_path,
)
if success:
completion_msg.text.append(
f"{self.client.config_file.name} backup created"
)
if remove_client:
if self._remove_client_dir():
completion_msg.text.append(f"{self.client.display_name} removed")
if self._remove_client_nginx_config(self.client.name):
completion_msg.text.append("● NGINX config removed")
if self._remove_client_nginx_logs(self.client, kl_instances):
completion_msg.text.append("● NGINX logs removed")
svc.backup_moonraker_conf()
section = f"update_manager {self.client.name}"
handled_instances = remove_config_section(section, mr_instances)
if handled_instances:
names = [i.service_file_path.stem for i in handled_instances]
completion_msg.text.append(
f"● Moonraker config section '{section}' removed for "
f"instance: {', '.join(names)}"
)
if remove_client_cfg:
cfg_svc = WebClientConfigSetupService(self.name)
cfg_message = cfg_svc.remove_config(
kl_instances=kl_instances,
mr_instances=mr_instances,
backup_config=backup_config,
svc=svc,
)
if cfg_message.color == Color.GREEN:
completion_msg.text.extend(cfg_message.text[1:])
if not completion_msg.text:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text.append("Nothing to remove.")
else:
completion_msg.text.insert(0, "The following actions were performed:")
return completion_msg
def _remove_client_dir(self) -> bool:
Logger.print_status(f"Removing {self.client.display_name} ...")
return bool(run_remove_routines(self.client.client_dir))
def _remove_client_nginx_config(self, name: str) -> bool:
Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...")
return bool(
remove_with_sudo([
NGINX_SITES_AVAILABLE.joinpath(name),
NGINX_SITES_ENABLED.joinpath(name),
])
)
def _remove_client_nginx_logs(
self, client: BaseWebClient, instances: List[Klipper]
) -> bool:
Logger.print_status(f"Removing NGINX logs for {client.display_name} ...")
files = [client.nginx_access_log, client.nginx_error_log]
if instances:
for instance in instances:
files.append(
instance.base.log_dir.joinpath(client.nginx_access_log.name)
)
files.append(
instance.base.log_dir.joinpath(client.nginx_error_log.name)
)
return bool(remove_with_sudo(files))
def _download_client(client: BaseWebClient) -> None:
zipfile = f"{client.name.lower()}.zip"
target = Path().home().joinpath(zipfile)
try:
Logger.print_status(
f"Downloading {client.display_name} from {client.download_url} ..."
)
download_file(client.download_url, target, True)
Logger.print_ok("Download complete!")
Logger.print_status(f"Extracting {zipfile} ...")
unzip(target, client.client_dir)
target.unlink(missing_ok=True)
Logger.print_ok("OK!")
except Exception:
Logger.print_error(f"Downloading {client.display_name} failed!")
raise
@@ -1,49 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from components.webui_client.base_data import WebClientType
@dataclass
class FakeClientConfig:
name: str = "mainsail-config"
display_name: str = "Mainsail-Config"
config_dir: Path = Path("/tmp/mainsail-config")
config_filename: str = "mainsail.cfg"
config_section: str = "include mainsail.cfg"
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
@dataclass
class FakeWebClient:
name: str = "mainsail"
display_name: str = "Mainsail"
client: WebClientType = WebClientType.MAINSAIL
client_dir: Path = Path("/tmp/mainsail")
config_file: Path = Path("/tmp/mainsail/config.json")
repo_path: str = "mainsail-crew/mainsail"
nginx_config: Path = Path("/tmp/nginx/mainsail")
nginx_access_log: Path = Path("/tmp/log/mainsail-access.log")
nginx_error_log: Path = Path("/tmp/log/mainsail-error.log")
download_url: str = "https://example.com/mainsail.zip"
client_config: Any = field(default_factory=FakeClientConfig)
@pytest.fixture
def client() -> FakeWebClient:
return FakeWebClient()
@pytest.fixture
def settings(monkeypatch: pytest.MonkeyPatch) -> Any:
from core.settings.kiauh_settings import KiauhSettings
KiauhSettings._KiauhSettings__instance = None
KiauhSettings._KiauhSettings__initialized = False
return KiauhSettings()
@@ -1,348 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, List
import pytest
from components.webui_client import client_utils
from components.webui_client.base_data import WebClientType
from components.webui_client.client_utils import (
backup_client_config_data,
backup_client_data,
create_client_config_symlink,
detect_client_cfg_conflict,
get_client_status,
get_current_client_config,
get_download_url,
get_local_client_version,
get_next_free_port,
get_nginx_listen_port,
get_remote_client_version,
read_ports_from_nginx_configs,
set_listen_port,
)
class TestGetLocalClientVersion:
def test_returns_none_when_client_dir_missing(self, client) -> None:
client.client_dir = Path("/does/not/exist")
assert get_local_client_version(client) is None
def test_reads_release_info_json(self, client, tmp_path: Path) -> None:
client.client_dir = tmp_path
release = tmp_path / "release_info.json"
release.write_text('{"version": "v2.0.0"}')
assert get_local_client_version(client) == "v2.0.0"
def test_falls_back_to_version_file(self, client, tmp_path: Path) -> None:
client.client_dir = tmp_path
(tmp_path / ".version").write_text("v1.2.3\n")
assert get_local_client_version(client) == "v1.2.3"
def test_returns_none_for_empty_version_file(self, client, tmp_path: Path) -> None:
client.client_dir = tmp_path
(tmp_path / ".version").write_text("")
assert get_local_client_version(client) is None
class TestGetRemoteClientVersion:
def test_returns_tag_when_available(self, monkeypatch, client) -> None:
monkeypatch.setattr(
client_utils, "get_latest_remote_tag", lambda repo: "v3.0.0"
)
assert get_remote_client_version(client) == "v3.0.0"
def test_returns_none_when_tag_empty(self, monkeypatch, client) -> None:
monkeypatch.setattr(client_utils, "get_latest_remote_tag", lambda repo: "")
assert get_remote_client_version(client) is None
def test_returns_none_on_error(self, monkeypatch, client) -> None:
monkeypatch.setattr(
client_utils,
"get_latest_remote_tag",
lambda repo: (_ for _ in ()).throw(RuntimeError("network")),
)
assert get_remote_client_version(client) is None
class TestGetDownloadUrl:
def test_returns_stable_url_when_not_unstable(self, monkeypatch, client) -> None:
class FakeSettings:
def get(self, name, key):
return False
monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings)
url = get_download_url("https://example.com/repo", client)
assert "latest/download" in url
def test_returns_unstable_url_when_available(self, monkeypatch, client) -> None:
class FakeSettings:
def get(self, name, key):
return True
monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings)
monkeypatch.setattr(
client_utils, "get_latest_unstable_tag", lambda repo: "v9.9.9"
)
url = get_download_url("https://example.com/repo", client)
assert "v9.9.9" in url
class TestDetectClientCfgConflict:
def test_mainsail_conflicts_with_fluidd_installed(
self, monkeypatch, client
) -> None:
def fake_status(c):
code = 2 if c.client == WebClientType.FLUIDD else 0
return type("S", (), {"status": code})()
monkeypatch.setattr(client_utils, "get_client_config_status", fake_status)
client.client = WebClientType.MAINSAIL
assert detect_client_cfg_conflict(client) is True
def test_fluidd_conflicts_with_mainsail_installed(
self, monkeypatch, client
) -> None:
def fake_status(c):
code = 2 if c.client == WebClientType.MAINSAIL else 0
return type("S", (), {"status": code})()
monkeypatch.setattr(client_utils, "get_client_config_status", fake_status)
client.client = WebClientType.FLUIDD
assert detect_client_cfg_conflict(client) is True
class TestGetNextFreePort:
def test_returns_lowest_unused_port(self) -> None:
assert get_next_free_port([80, 81]) == 82
def test_starts_at_80(self) -> None:
assert get_next_free_port([]) == 80
class TestNginxPortParsing:
def test_parses_plain_listen_port(self, tmp_path: Path) -> None:
cfg = tmp_path / "site"
cfg.write_text("server {\n listen 8080;\n}\n")
assert get_nginx_listen_port(cfg) == 8080
def test_parses_listen_port_with_host(self, tmp_path: Path) -> None:
cfg = tmp_path / "site"
cfg.write_text("server {\n listen 127.0.0.1:9090;\n}\n")
assert get_nginx_listen_port(cfg) == 9090
def test_returns_none_when_no_listen(self, tmp_path: Path) -> None:
cfg = tmp_path / "site"
cfg.write_text("server {\n}\n")
assert get_nginx_listen_port(cfg) is None
def test_reads_all_configs_in_enabled_dir(
self, monkeypatch, tmp_path: Path
) -> None:
sites = tmp_path / "sites-enabled"
sites.mkdir()
(sites / "a").write_text("listen 1000;")
(sites / "b").write_text("listen 2000;")
monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", sites)
ports = read_ports_from_nginx_configs()
assert ports == [1000, 2000]
def test_returns_empty_when_enabled_dir_missing(self, monkeypatch) -> None:
monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", Path("/missing"))
assert read_ports_from_nginx_configs() == []
class TestSetListenPort:
def test_replaces_port_in_config(
self, client, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
client.name = "mainsail"
monkeypatch.setattr(client_utils, "NGINX_SITES_AVAILABLE", tmp_path)
cfg = tmp_path / "mainsail"
cfg.write_text("server {\n listen 80;\n}\n")
set_listen_port(client, 80, 8080)
assert "listen 8080" in cfg.read_text()
class TestCreateClientConfigSymlink:
def test_creates_symlink_per_instance(
self, monkeypatch, client, tmp_path: Path
) -> None:
client.client_config.config_dir = tmp_path / "cfg"
client.client_config.config_filename = "mainsail.cfg"
called: List[Any] = []
monkeypatch.setattr(
client_utils, "create_symlink", lambda s, t: called.append((s, t))
)
class FakeInstance:
base = type("Base", (), {"cfg_dir": tmp_path / "printer"})()
create_client_config_symlink(client.client_config, [FakeInstance()])
assert len(called) == 1
def test_symlink_failure_logs_error_and_continues(
self, monkeypatch, client, tmp_path: Path
) -> None:
client.client_config.config_dir = tmp_path / "cfg"
client.client_config.config_filename = "mainsail.cfg"
attempt: List[Any] = []
def flaky_create_symlink(source, target) -> None:
attempt.append(target)
if len(attempt) == 1:
raise RuntimeError("permission denied")
monkeypatch.setattr(client_utils, "create_symlink", flaky_create_symlink)
errors: List[str] = []
monkeypatch.setattr(
client_utils.Logger,
"print_error",
lambda msg, *a, **k: errors.append(str(msg)),
)
class FakeInstance:
def __init__(self, cfg: Path) -> None:
self.base = type("Base", (), {"cfg_dir": cfg})()
create_client_config_symlink(
client.client_config,
[FakeInstance(tmp_path / "a"), FakeInstance(tmp_path / "b")],
)
assert len(attempt) == 2 # failure did not abort the loop
assert any("symlink" in m.lower() for m in errors)
class TestBackupClientData:
def test_backs_up_client_dir_and_config_file(
self, monkeypatch, client, tmp_path: Path
) -> None:
client.client_dir = tmp_path / "mainsail"
client.client_dir.mkdir()
(client.client_dir / ".version").write_text("v1\n")
client.config_file = client.client_dir / "config.json"
client.config_file.write_text("{}")
calls: List[str] = []
class FakeBackup:
backup_root = tmp_path / "backups"
def backup_directory(self, **kwargs):
calls.append("dir")
def backup_file(self, **kwargs):
calls.append("file")
monkeypatch.setattr(client_utils, "BackupService", FakeBackup)
backup_client_data(client)
assert "dir" in calls
assert "file" in calls
class TestBackupClientConfigData:
def test_backs_up_config_dir(self, monkeypatch, client, tmp_path: Path) -> None:
client.client_dir = tmp_path / "mainsail"
client.client_dir.mkdir()
(client.client_dir / ".version").write_text("v1\n")
client.client_config.config_dir = tmp_path / "mainsail-config"
calls: List[str] = []
class FakeBackup:
backup_root = tmp_path / "backups"
def backup_directory(self, **kwargs):
calls.append("dir")
monkeypatch.setattr(client_utils, "BackupService", FakeBackup)
backup_client_config_data(client)
assert "dir" in calls
class TestGetClientStatus:
def test_sets_status_not_installed_when_dir_missing(
self, monkeypatch, client, tmp_path: Path
) -> None:
client.client_dir = tmp_path / "missing"
monkeypatch.setattr(
client_utils,
"get_install_status",
lambda *args, **kwargs: type(
"S", (), {"status": 2, "local": None, "remote": None}
)(),
)
status = get_client_status(client)
assert status.status == 0
class TestGetCurrentClientConfig:
def test_returns_dash_when_no_config_dirs(self, monkeypatch) -> None:
monkeypatch.setattr(
client_utils,
"MainsailData",
lambda: type(
"M",
(),
{
"client_config": type(
"C", (), {"config_dir": Path("/no/mainsail")}
)()
},
)(),
)
monkeypatch.setattr(
client_utils,
"FluiddData",
lambda: type(
"F",
(),
{"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()},
)(),
)
result = get_current_client_config()
assert "-" in result
def test_returns_single_installed_name(self, monkeypatch, tmp_path: Path) -> None:
cfg_dir = tmp_path / "mainsail-config"
cfg_dir.mkdir()
monkeypatch.setattr(
client_utils,
"MainsailData",
lambda: type(
"M",
(),
{
"client_config": type(
"C",
(),
{"config_dir": cfg_dir, "display_name": "Mainsail-Config"},
)()
},
)(),
)
monkeypatch.setattr(
client_utils,
"FluiddData",
lambda: type(
"F",
(),
{"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()},
)(),
)
result = get_current_client_config()
assert "Mainsail-Config" in result
-25
View File
@@ -1,25 +0,0 @@
# ======================================================================= #
# 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 pytest
@pytest.fixture(autouse=True)
def silence_logger(monkeypatch: pytest.MonkeyPatch) -> None:
"""Suppress logger output so tests produce clean, assertion-focused output."""
for name in (
"print_info",
"print_ok",
"print_warn",
"print_error",
"print_status",
"print_dialog",
):
monkeypatch.setattr(f"core.logger.Logger.{name}", lambda *a, **k: None)
-182
View File
@@ -1,182 +0,0 @@
# ======================================================================= #
# 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 shutil
import subprocess
from pathlib import Path
from typing import Any, List, Protocol, Sequence, cast, runtime_checkable
# --------------------------------------------------------------------------- #
# Singleton backends #
# --------------------------------------------------------------------------- #
# There is exactly ONE ``command_runner`` and ONE ``filesystem`` global in the
# whole project, owned by this module. They are assigned (with explicit type
# annotations) at the bottom of this file, AFTER the default implementations
# are defined. ``utils.fs_utils`` and ``utils.sys_utils`` delegate to these
# singletons via the wrapper functions below, so tests patch a single place —
# ``core.backends.command_runner`` / ``core.backends.filesystem`` — instead of
# per-module duplicates
def run(cmd: str | List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command through the shared command runner."""
return command_runner.run(cmd, **kwargs)
def check_output(cmd: str | List[str], **kwargs: Any) -> str | bytes:
"""Run a command and return its output through the shared command runner."""
return command_runner.check_output(cmd, **kwargs)
def call(cmd: str | List[str], **kwargs: Any) -> int:
"""Run a command and return its exit code through the shared command runner."""
return command_runner.call(cmd, **kwargs)
def popen(cmd: str | List[str], **kwargs: Any) -> subprocess.Popen:
"""Start a process through the shared command runner."""
return command_runner.popen(cmd, **kwargs)
@runtime_checkable
class CommandRunner(Protocol):
"""Pluggable backend for executing system commands."""
def run(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.CompletedProcess: ...
def check_output(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> str | bytes: ...
def call(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> int: ...
def popen(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.Popen: ...
class SubprocessRunner:
"""Default command runner backed by the standard subprocess module."""
def run(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, **kwargs)
def check_output(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> str | bytes:
return cast("str | bytes", subprocess.check_output(cmd, **kwargs))
def call(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> int:
return subprocess.call(cmd, **kwargs)
def popen(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.Popen:
return subprocess.Popen(cmd, **kwargs)
@runtime_checkable
class FilesystemBackend(Protocol):
"""Pluggable backend for filesystem operations."""
def exists(self, path: Path) -> bool: ...
def is_dir(self, path: Path) -> bool: ...
def is_file(self, path: Path) -> bool: ...
def is_symlink(self, path: Path) -> bool: ...
def mkdir(
self, path: Path, *, parents: bool = False, exist_ok: bool = False
) -> None: ...
def unlink(self, path: Path) -> None: ...
def rmtree(self, path: Path) -> None: ...
def read_text(self, path: Path) -> str: ...
def write_text(self, path: Path, content: str) -> None: ...
def copy(self, source: Path, target: Path) -> None: ...
def home(self) -> Path: ...
class LocalFilesystemBackend:
"""Default filesystem backend backed by the local filesystem."""
def exists(self, path: Path) -> bool:
return path.exists()
def is_dir(self, path: Path) -> bool:
return path.is_dir()
def is_file(self, path: Path) -> bool:
return path.is_file()
def is_symlink(self, path: Path) -> bool:
return path.is_symlink()
def mkdir(
self, path: Path, *, parents: bool = False, exist_ok: bool = False
) -> None:
path.mkdir(parents=parents, exist_ok=exist_ok)
def unlink(self, path: Path) -> None:
path.unlink()
def rmtree(self, path: Path) -> None:
shutil.rmtree(path)
def read_text(self, path: Path) -> str:
return path.read_text()
def write_text(self, path: Path, content: str) -> None:
path.write_text(content)
def copy(self, source: Path, target: Path) -> None:
if source.is_dir():
shutil.copytree(source, target)
else:
shutil.copy2(source, target)
def home(self) -> Path:
return Path.home()
command_runner: CommandRunner = SubprocessRunner()
filesystem: FilesystemBackend = LocalFilesystemBackend()
-101
View File
@@ -1,101 +0,0 @@
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from core import backends
from core.backends import LocalFilesystemBackend, SubprocessRunner
from tests.helpers.fake_backends import FakeCommandRunner, FakeFilesystemBackend
from utils import fs_utils, sys_utils
class TestSubprocessRunner:
def test_run_executes_command(self) -> None:
runner = SubprocessRunner()
result = runner.run(["true"])
assert result.returncode == 0
def test_check_output_returns_stdout(self) -> None:
runner = SubprocessRunner()
output = runner.check_output(["echo", "hello"], text=True)
assert "hello" in output
class TestCommandRunnerInjection:
def test_sys_utils_uses_injected_runner(self, monkeypatch) -> None:
fake = FakeCommandRunner({
("some", "cmd"): subprocess.CompletedProcess(["some", "cmd"], 0, "", "")
})
monkeypatch.setattr(backends, "command_runner", fake)
sys_utils.run(["some", "cmd"], check=True)
assert fake.calls[0][0] == ["some", "cmd"]
assert fake.calls[0][1].get("check") is True
def test_cmd_sysctl_service_records_command(self, monkeypatch) -> None:
expected_cmd = ["sudo", "systemctl", "start", "klipper.service"]
fake = FakeCommandRunner({
tuple(expected_cmd): subprocess.CompletedProcess(expected_cmd, 0, "", "")
})
monkeypatch.setattr(backends, "command_runner", fake)
sys_utils.cmd_sysctl_service("klipper.service", "start")
assert fake.calls[0][0] == expected_cmd
@pytest.mark.parametrize("module", [sys_utils, fs_utils])
def test_single_shared_command_runner_registry(self, monkeypatch, module) -> None:
# there is only ONE ``command_runner`` global to patch.
# Patching ``core.backends.command_runner`` must affect every wrapper
# (sys_utils.run, fs_utils.run, enum helpers) — no per-module duplicates.
fake = FakeCommandRunner({
("shared", "cmd"): subprocess.CompletedProcess(["shared", "cmd"], 0, "", "")
})
monkeypatch.setattr(backends, "command_runner", fake)
module.run(["shared", "cmd"], check=True)
assert fake.calls[0][0] == ["shared", "cmd"]
class TestLocalFilesystemBackend:
def test_write_and_read_text(self, tmp_path: Path) -> None:
fs = LocalFilesystemBackend()
target = tmp_path / "test.txt"
fs.write_text(target, "hello")
assert fs.read_text(target) == "hello"
def test_mkdir_and_exists(self, tmp_path: Path) -> None:
fs = LocalFilesystemBackend()
target = tmp_path / "new_dir"
assert not fs.exists(target)
fs.mkdir(target)
assert fs.exists(target)
assert fs.is_dir(target)
class TestFilesystemBackendInjection:
def test_create_folders_uses_injected_fs(self, monkeypatch) -> None:
fake = FakeFilesystemBackend()
monkeypatch.setattr(backends, "filesystem", fake)
fs_utils.create_folders([Path("/tmp/a"), Path("/tmp/b")])
assert fake.exists(Path("/tmp/a"))
assert fake.exists(Path("/tmp/b"))
def test_run_remove_routines_uses_injected_fs(self, monkeypatch) -> None:
fake = FakeFilesystemBackend()
fake.add_file(Path("/tmp/file.txt"), "x")
monkeypatch.setattr(backends, "filesystem", fake)
assert fs_utils.run_remove_routines(Path("/tmp/file.txt")) is True
assert not fake.exists(Path("/tmp/file.txt"))
def test_run_remove_routines_skips_missing_file(self, monkeypatch) -> None:
fake = FakeFilesystemBackend()
monkeypatch.setattr(backends, "filesystem", fake)
assert fs_utils.run_remove_routines(Path("/tmp/missing")) is False
-365
View File
@@ -1,365 +0,0 @@
# ======================================================================= #
# 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
@@ -1,517 +0,0 @@
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
@@ -1,109 +0,0 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import List
import pytest
from core.instance_manager.instance_manager import InstanceManager
from tests.helpers.fake_backends import FakeCommandRunner
class FakeInstance:
def __init__(self, name: str, log_dir: Path | None = None) -> None:
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
self.log_file_name = "klipper.log"
self.base = type("Base", (), {"log_dir": log_dir})()
def _runner_for(*commands: List[str]) -> FakeCommandRunner:
"""Return a strict FakeCommandRunner with success responses for commands."""
responses = {
tuple(cmd): subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr=""
)
for cmd in commands
}
return FakeCommandRunner(responses)
class TestInstanceManager:
def test_start_records_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
cmd = ["sudo", "systemctl", "start", "klipper.service"]
fake = _runner_for(cmd)
monkeypatch.setattr("core.backends.command_runner", fake)
instance = FakeInstance("klipper")
InstanceManager.start(instance)
assert fake.calls[0][0] == cmd
def test_stop_records_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
cmd = ["sudo", "systemctl", "stop", "klipper.service"]
fake = _runner_for(cmd)
monkeypatch.setattr("core.backends.command_runner", fake)
instance = FakeInstance("klipper")
InstanceManager.stop(instance)
assert fake.calls[0][0] == cmd
def test_restart_records_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
cmd = ["sudo", "systemctl", "restart", "klipper.service"]
fake = _runner_for(cmd)
monkeypatch.setattr("core.backends.command_runner", fake)
instance = FakeInstance("klipper")
InstanceManager.restart(instance)
assert fake.calls[0][0] == cmd
def test_enable_records_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
cmd = ["sudo", "systemctl", "enable", "klipper.service"]
fake = _runner_for(cmd)
monkeypatch.setattr("core.backends.command_runner", fake)
instance = FakeInstance("klipper")
InstanceManager.enable(instance)
assert fake.calls[0][0] == cmd
def test_disable_records_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
cmd = ["sudo", "systemctl", "disable", "klipper.service"]
fake = _runner_for(cmd)
monkeypatch.setattr("core.backends.command_runner", fake)
instance = FakeInstance("klipper")
InstanceManager.disable(instance)
assert fake.calls[0][0] == cmd
def test_start_all_iterates_instances(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
commands = [
["sudo", "systemctl", "start", "klipper-1.service"],
["sudo", "systemctl", "start", "klipper-2.service"],
]
fake = _runner_for(*commands)
monkeypatch.setattr("core.backends.command_runner", fake)
instances = [FakeInstance("klipper-1"), FakeInstance("klipper-2")]
InstanceManager.start_all(instances)
recorded = [call[0] for call in fake.calls]
assert recorded == commands
def test_stop_all_iterates_instances(self, monkeypatch: pytest.MonkeyPatch) -> None:
commands = [
["sudo", "systemctl", "stop", "klipper-a.service"],
["sudo", "systemctl", "stop", "klipper-b.service"],
]
fake = _runner_for(*commands)
monkeypatch.setattr("core.backends.command_runner", fake)
instances = [FakeInstance("klipper-a"), FakeInstance("klipper-b")]
InstanceManager.stop_all(instances)
recorded = [call[0] for call in fake.calls]
assert recorded == commands
+9 -10
View File
@@ -15,17 +15,16 @@ from components.crowsnest.crowsnest import install_crowsnest
from components.klipper.services.klipper_setup_service import KlipperSetupService
from components.klipperscreen.klipperscreen import install_klipperscreen
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
from components.webui_client.client_config.client_config_setup import (
install_client_config,
)
from components.webui_client.client_setup import install_client
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from components.webui_client.menus.client_install_menu import ClientInstallMenu
from components.webui_client.services.web_client_config_setup_service import (
WebClientConfigSetupService,
)
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
@@ -88,20 +87,20 @@ class InstallMenu(BaseMenu):
if client.client_dir.exists():
ClientInstallMenu(client, self.__class__).run()
else:
WebClientSetupService("mainsail").install()
install_client(client, settings=KiauhSettings())
def install_mainsail_config(self, **kwargs) -> None:
WebClientConfigSetupService("mainsail").install()
install_client_config(MainsailData())
def install_fluidd(self, **kwargs) -> None:
client: FluiddData = FluiddData()
if client.client_dir.exists():
ClientInstallMenu(client, self.__class__).run()
else:
WebClientSetupService("fluidd").install()
install_client(client, settings=KiauhSettings())
def install_fluidd_config(self, **kwargs) -> None:
WebClientConfigSetupService("fluidd").install()
install_client_config(FluiddData())
def install_klipperscreen(self, **kwargs) -> None:
install_klipperscreen()
+2 -2
View File
@@ -10,13 +10,13 @@ from __future__ import annotations
from typing import List, Literal, Type
from core.logger import DialogType, Logger
from core.logger import Logger, DialogType
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.settings.kiauh_settings import KiauhSettings, Repository
from core.types.color import Color
from procedures.switch_repo import run_switch_repo_routine
from utils.input_utils import get_confirm, get_number_input, get_string_input
from utils.input_utils import get_string_input, get_number_input, get_confirm
# noinspection PyUnusedLocal
View File
-94
View File
@@ -1,94 +0,0 @@
from __future__ import annotations
from typing import List, Type
import pytest
from core.menus import FooterType, Option
from core.menus.base_menu import (
BaseMenu,
MenuTitleStyle,
PostInitCaller,
print_back_footer,
print_back_help_footer,
print_blank_footer,
print_header,
print_quit_footer,
)
class ConcreteMenu(BaseMenu, metaclass=PostInitCaller):
title = "Concrete"
footer_type = FooterType.BACK
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = previous_menu
def set_options(self) -> None:
self.options = {
"1": Option(method=lambda **k: None),
}
def print_menu(self) -> None:
pass
@pytest.fixture
def concrete(monkeypatch: pytest.MonkeyPatch) -> ConcreteMenu:
monkeypatch.setattr("core.menus.base_menu.print_header", lambda: None)
return ConcreteMenu()
class TestBaseMenuHelpers:
def test_print_header_outputs_banner(self, capsys) -> None:
print_header()
captured = capsys.readouterr()
assert "KIAUH" in captured.out
def test_print_quit_footer(self, capsys) -> None:
print_quit_footer()
assert "Quit" in capsys.readouterr().out
def test_print_back_footer(self, capsys) -> None:
print_back_footer()
assert "Back" in capsys.readouterr().out
def test_print_back_help_footer(self, capsys) -> None:
print_back_help_footer()
out = capsys.readouterr().out
assert "Back" in out
assert "Help" in out
def test_print_blank_footer(self, capsys) -> None:
print_blank_footer()
assert "" in capsys.readouterr().out
class TestBaseMenuLifecycle:
def test_direct_instantiation_raises(self) -> None:
with pytest.raises(NotImplementedError):
BaseMenu()
def test_options_include_back_for_back_footer(self, concrete: ConcreteMenu) -> None:
assert "b" in concrete.options
def test_go_back_does_nothing_without_previous_menu(
self, concrete: ConcreteMenu
) -> None:
concrete.previous_menu = None
# should not raise
concrete._BaseMenu__go_back()
def test_exit_calls_system_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
exits: List[int] = []
monkeypatch.setattr("core.menus.base_menu.sys.exit", lambda c: exits.append(c))
menu = ConcreteMenu()
menu._BaseMenu__exit()
assert exits == [0]
class TestMenuTitleStyle:
def test_style_values(self) -> None:
assert MenuTitleStyle.PLAIN.value == "plain"
assert MenuTitleStyle.STYLED.value == "styled"
-161
View File
@@ -1,161 +0,0 @@
# ======================================================================= #
# 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
from typing import Any, List
import core.menus.install_menu as install_menu_module
import pytest
from core.menus.install_menu import InstallMenu
@pytest.fixture
def menu(monkeypatch: pytest.MonkeyPatch) -> InstallMenu:
# Avoid the heavyweight singleton setup services loading real instances.
monkeypatch.setattr(install_menu_module, "KlipperSetupService", lambda: object())
monkeypatch.setattr(install_menu_module, "MoonrakerSetupService", lambda: object())
return InstallMenu()
def _fake_data(client_dir_exists: bool) -> Any:
return type(
"Client",
(),
{
"client_dir": type(
"P",
(),
{"exists": lambda self: client_dir_exists},
)(),
},
)()
class TestInstallMenuWiring:
def test_options_expose_every_install_entry(self, menu: InstallMenu) -> None:
for key in ("1", "2", "3", "4", "5", "6", "7", "8"):
assert key in menu.options
def test_set_previous_menu_defaults_to_main_menu(
self, menu: InstallMenu, monkeypatch
) -> None:
# importing MainMenu here avoids an import cycle in the module under test
from core.menus.main_menu import MainMenu
menu.set_previous_menu(None)
assert menu.previous_menu is MainMenu
def test_install_mainsail_when_absent_calls_setup_service(
self, menu: InstallMenu, monkeypatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
install_menu_module, "MainsailData", lambda: _fake_data(False)
)
monkeypatch.setattr(
install_menu_module,
"WebClientSetupService",
lambda name: type(
"S", (), {"install": lambda self: calls.append(name) or True}
)(),
)
menu.install_mainsail()
assert calls == ["mainsail"]
def test_install_mainsail_when_present_opens_client_install_menu(
self, menu: InstallMenu, monkeypatch
) -> None:
opened: List[Any] = []
monkeypatch.setattr(
install_menu_module, "MainsailData", lambda: _fake_data(True)
)
class _FakeClientInstallMenu:
def __init__(self, client, previous_menu) -> None:
opened.append((client, previous_menu))
def run(self) -> None:
pass
monkeypatch.setattr(
install_menu_module, "ClientInstallMenu", _FakeClientInstallMenu
)
menu.install_mainsail()
assert len(opened) == 1
def test_install_fluidd_when_absent_calls_setup_service(
self, menu: InstallMenu, monkeypatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
install_menu_module, "FluiddData", lambda: _fake_data(False)
)
monkeypatch.setattr(
install_menu_module,
"WebClientSetupService",
lambda name: type(
"S", (), {"install": lambda self: calls.append(name) or True}
)(),
)
menu.install_fluidd()
assert calls == ["fluidd"]
def test_install_mainsail_config_delegates_to_config_service(
self, menu: InstallMenu, monkeypatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
install_menu_module,
"WebClientConfigSetupService",
lambda name: type(
"S", (), {"install": lambda self: calls.append(name) or True}
)(),
)
menu.install_mainsail_config()
assert calls == ["mainsail"]
def test_install_fluidd_config_delegates_to_config_service(
self, menu: InstallMenu, monkeypatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
install_menu_module,
"WebClientConfigSetupService",
lambda name: type(
"S", (), {"install": lambda self: calls.append(name) or True}
)(),
)
menu.install_fluidd_config()
assert calls == ["fluidd"]
def test_install_klipperscreen_and_crowsnest_delegates(
self, menu: InstallMenu, monkeypatch
) -> None:
calls: List[str] = []
monkeypatch.setattr(
install_menu_module, "install_klipperscreen", lambda: calls.append("ks")
)
monkeypatch.setattr(
install_menu_module, "install_crowsnest", lambda: calls.append("cn")
)
menu.install_klipperscreen()
menu.install_crowsnest()
assert calls == ["ks", "cn"]
@@ -1,69 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from core.menus.main_menu import MainMenu
@pytest.fixture
def fake_menu(monkeypatch: pytest.MonkeyPatch):
"""Provide an isolated fake menu class and a call log for each test."""
calls: List[Dict[str, Any]] = []
class FakeMenu:
def __init__(self, **kwargs: Any) -> None:
calls.append(kwargs)
def run(self) -> None:
pass
yield FakeMenu, calls
@pytest.fixture
def reset_main_menu(monkeypatch: pytest.MonkeyPatch) -> None:
# silence status fetching during menu construction if any
monkeypatch.setattr(
"core.menus.main_menu.MainMenu._fetch_status", lambda self: None
)
@pytest.mark.parametrize(
"option_key, target",
[
("1", "InstallMenu"),
("2", "UpdateMenu"),
("3", "RemoveMenu"),
("4", "AdvancedMenu"),
("5", "BackupMenu"),
("s", "SettingsMenu"),
("e", "ExtensionsMenu"),
],
)
def test_main_menu_routes_to_submenu(
option_key: str,
target: str,
monkeypatch: pytest.MonkeyPatch,
reset_main_menu,
fake_menu,
) -> None:
fake_menu_cls, calls = fake_menu
monkeypatch.setattr(f"core.menus.main_menu.{target}", fake_menu_cls)
menu = MainMenu()
option = menu.options[option_key]
option.method(opt_index=option.opt_index, opt_data=option.opt_data)
assert len(calls) == 1
assert calls[0].get("previous_menu") is MainMenu
def test_main_menu_quit_exits(monkeypatch: pytest.MonkeyPatch, reset_main_menu) -> None:
exits: List[int] = []
monkeypatch.setattr("core.menus.main_menu.sys.exit", lambda code: exits.append(code))
menu = MainMenu()
menu.options["q"].method()
assert exits == [0]
@@ -1,84 +0,0 @@
from __future__ import annotations
from typing import Any, List
import pytest
from core.menus.repo_select_menu import RepoSelectMenu
class FakeRepo:
def __init__(self, url: str = "https://example.com/repo.git", branch: str = "master") -> None:
self.url = url
self.branch = branch
@pytest.fixture
def patched_menu(monkeypatch: pytest.MonkeyPatch) -> RepoSelectMenu:
class FakeSettings:
class _K:
repositories: List[Any] = []
class _M:
repositories: List[Any] = []
klipper = _K()
moonraker = _M()
def save(self) -> None:
pass
monkeypatch.setattr(
"core.menus.repo_select_menu.KiauhSettings", lambda: FakeSettings()
)
monkeypatch.setattr(
"core.menus.repo_select_menu.run_switch_repo_routine",
lambda *a, **k: None,
)
return RepoSelectMenu("klipper", repos=[FakeRepo()])
class TestRepoSelectMenuConstruction:
def test_title_for_klipper(self) -> None:
menu = RepoSelectMenu("klipper", repos=[])
assert "Klipper" in menu.title
def test_title_for_moonraker(self) -> None:
menu = RepoSelectMenu("moonraker", repos=[])
assert "Moonraker" in menu.title
def test_options_include_add_remove_back(
self, patched_menu: RepoSelectMenu
) -> None:
assert "a" in patched_menu.options
assert "r" in patched_menu.options
assert "b" in patched_menu.options
def test_repository_options_are_indexed(
self, patched_menu: RepoSelectMenu
) -> None:
assert "1" in patched_menu.options
class TestRepoSelectMenuActions:
def test_select_repository_runs_switch_routine(
self, patched_menu: RepoSelectMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
called: List[Any] = []
monkeypatch.setattr(
"core.menus.repo_select_menu.run_switch_repo_routine",
lambda name, url, branch: called.append((name, url, branch)),
)
repo = FakeRepo("https://github.com/k/klipper.git", "main")
patched_menu.select_repository(opt_data=repo)
assert called == [("klipper", "https://github.com/k/klipper.git", "main")]
def test_remove_repository_does_nothing_when_empty(
self, patched_menu: RepoSelectMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
patched_menu.repos = []
patched_menu.set_options()
# should not raise
patched_menu.remove_repository()
@@ -1,65 +0,0 @@
from __future__ import annotations
import pytest
from core.menus.settings_menu import SettingsMenu
@pytest.fixture
def patched_settings_menu(monkeypatch: pytest.MonkeyPatch) -> SettingsMenu:
class FakeRepo:
def __init__(self):
self.repositories = []
class FakeKiauh:
backup_before_update = True
class FakeSettings:
kiauh = FakeKiauh()
mainsail = type("M", (), {"unstable_releases": False})()
fluidd = type("F", (), {"unstable_releases": False})()
klipper = FakeRepo()
moonraker = FakeRepo()
def save(self) -> None:
pass
monkeypatch.setattr(
"core.menus.settings_menu.KiauhSettings", lambda: FakeSettings()
)
monkeypatch.setattr(
"core.menus.settings_menu.get_klipper_status",
lambda: type("S", (), {"repo": None, "repo_url": "", "branch": ""})(),
)
monkeypatch.setattr(
"core.menus.settings_menu.get_moonraker_status",
lambda: type("S", (), {"repo": None, "repo_url": "", "branch": ""})(),
)
return SettingsMenu()
class TestSettingsMenuConstruction:
def test_options_cover_settings(self, patched_settings_menu: SettingsMenu) -> None:
assert {"1", "2", "3", "4", "5"}.issubset(patched_settings_menu.options)
def test_loads_backup_setting(self, patched_settings_menu: SettingsMenu) -> None:
assert patched_settings_menu.auto_backups_enabled is True
class TestToggleMethods:
def test_toggle_mainsail_release(self, patched_settings_menu: SettingsMenu) -> None:
patched_settings_menu.mainsail_unstable = False
patched_settings_menu.toggle_mainsail_release()
assert patched_settings_menu.mainsail_unstable is True
def test_toggle_fluidd_release(self, patched_settings_menu: SettingsMenu) -> None:
patched_settings_menu.fluidd_unstable = False
patched_settings_menu.toggle_fluidd_release()
assert patched_settings_menu.fluidd_unstable is True
def test_toggle_backup_before_update(
self, patched_settings_menu: SettingsMenu
) -> None:
patched_settings_menu.auto_backups_enabled = True
patched_settings_menu.toggle_backup_before_update()
assert patched_settings_menu.auto_backups_enabled is False
-210
View File
@@ -1,210 +0,0 @@
from __future__ import annotations
from typing import Any, List
import pytest
from core.menus.update_menu import UpdateMenu
def _make_status(status: int = 2, local: str | None = "v1", remote: str | None = "v2"):
return type(
"ComponentStatus", (), {"status": status, "local": local, "remote": remote}
)()
@pytest.fixture
def patched_menu(monkeypatch: pytest.MonkeyPatch) -> UpdateMenu:
monkeypatch.setattr(
"core.menus.update_menu.get_klipper_status",
lambda: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.get_moonraker_status",
lambda: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.get_client_status",
lambda *args, **kwargs: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.get_client_config_status",
lambda *args, **kwargs: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.get_klipperscreen_status",
lambda: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.get_crowsnest_status",
lambda: _make_status(),
)
monkeypatch.setattr(
"core.menus.update_menu.update_system_package_lists", lambda silent: None
)
monkeypatch.setattr("core.menus.update_menu.get_upgradable_packages", lambda: [])
class FakeSpinner:
def __init__(self, *a, **k):
pass
def start(self):
pass
def stop(self):
pass
monkeypatch.setattr("core.menus.base_menu.Spinner", FakeSpinner)
return UpdateMenu()
class TestUpdateMenuConstruction:
def test_options_cover_all_components(self, patched_menu: UpdateMenu) -> None:
expected = {"a", "1", "2", "3", "4", "5", "6", "7", "8", "9", "b"}
assert set(patched_menu.options.keys()) == expected
def test_status_data_marked_installed(self, patched_menu: UpdateMenu) -> None:
for name in ["klipper", "moonraker", "mainsail", "fluidd"]:
assert patched_menu.status_data[name]["installed"] is True
class TestUpdateRoutine:
def test_run_update_routine_skips_not_installed(
self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
patched_menu.status_data["klipper"]["installed"] = False
called: List[Any] = []
patched_menu._run_update_routine("klipper", lambda: called.append(True))
assert called == []
def test_run_update_routine_skips_up_to_date(
self, patched_menu: UpdateMenu
) -> None:
patched_menu.status_data["klipper"]["local"] = "v1"
patched_menu.status_data["klipper"]["remote"] = "v1"
called: List[Any] = []
patched_menu._run_update_routine("klipper", lambda: called.append(True))
assert called == []
def test_run_update_routine_executes_when_update_available(
self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
patched_menu.status_data["klipper"]["installed"] = True
patched_menu.status_data["klipper"]["local"] = "v1"
patched_menu.status_data["klipper"]["remote"] = "v2"
called: List[Any] = []
monkeypatch.setattr(
"core.menus.update_menu.get_klipper_status", lambda: _make_status()
)
patched_menu._run_update_routine("klipper", lambda: called.append(True))
assert called == [True]
class TestSystemUpdates:
def test_no_packages_logs_info(self, patched_menu: UpdateMenu) -> None:
patched_menu.packages = []
# should not raise
patched_menu._run_system_updates()
def test_fetch_status_translates_runtime_error_to_warning(
self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
# when ``apt-get update`` fails, ``update_system_package_lists``
# raises ``RuntimeError``. The update menu is a presentation boundary —
# it must catch, log a warning and show an empty upgradable list instead
# of crashing the menu.
def _raise(*_a, **_k):
raise RuntimeError("apt-get update failed")
monkeypatch.setattr(
"core.menus.update_menu.update_system_package_lists", _raise
)
monkeypatch.setattr(
"core.menus.update_menu.get_upgradable_packages", lambda: []
)
patched_menu._fetch_system_package_update_status()
assert patched_menu.packages == []
assert patched_menu.package_count == 0
def test_packages_trigger_upgrade_flow(
self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
patched_menu.packages = ["curl", "git"]
upgraded: List[List[str]] = []
monkeypatch.setattr("core.menus.update_menu.get_confirm", lambda *a, **k: True)
monkeypatch.setattr(
"core.menus.update_menu.upgrade_system_packages",
lambda pkgs: upgraded.append(pkgs),
)
monkeypatch.setattr(
"core.menus.update_menu.update_system_package_lists", lambda silent: None
)
monkeypatch.setattr(
"core.menus.update_menu.get_upgradable_packages", lambda: []
)
patched_menu._run_system_updates()
assert upgraded == [["curl", "git"]]
class TestUpdateAll:
def test_update_all_invokes_each_component_update(
self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: List[str] = []
monkeypatch.setattr(
patched_menu, "update_klipper", lambda **k: calls.append("klipper")
)
monkeypatch.setattr(
patched_menu, "update_moonraker", lambda **k: calls.append("moonraker")
)
monkeypatch.setattr(
patched_menu, "update_mainsail", lambda **k: calls.append("mainsail")
)
monkeypatch.setattr(
patched_menu,
"update_mainsail_config",
lambda **k: calls.append("mainsail_config"),
)
monkeypatch.setattr(
patched_menu, "update_fluidd", lambda **k: calls.append("fluidd")
)
monkeypatch.setattr(
patched_menu,
"update_fluidd_config",
lambda **k: calls.append("fluidd_config"),
)
monkeypatch.setattr(
patched_menu,
"update_klipperscreen",
lambda **k: calls.append("klipperscreen"),
)
monkeypatch.setattr(
patched_menu, "update_crowsnest", lambda **k: calls.append("crowsnest")
)
monkeypatch.setattr(
patched_menu, "upgrade_system_packages", lambda **k: calls.append("system")
)
patched_menu.update_all()
assert set(calls) == {
"klipper",
"moonraker",
"mainsail",
"mainsail_config",
"fluidd",
"fluidd_config",
"klipperscreen",
"crowsnest",
"system",
}
+13 -20
View File
@@ -22,18 +22,16 @@ from components.klipperscreen.klipperscreen import (
)
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
from components.moonraker.utils.utils import get_moonraker_status
from components.webui_client.client_config.client_config_setup import (
update_client_config,
)
from components.webui_client.client_setup import update_client
from components.webui_client.client_utils import (
get_client_config_status,
get_client_status,
)
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from components.webui_client.services.web_client_config_setup_service import (
WebClientConfigSetupService,
)
from components.webui_client.services.web_client_setup_service import (
WebClientSetupService,
)
from core.logger import DialogType, Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
@@ -205,25 +203,29 @@ class UpdateMenu(BaseMenu):
def update_mainsail(self, **kwargs) -> None:
self._run_update_routine(
"mainsail",
WebClientSetupService("mainsail").update,
update_client,
self.mainsail_data,
)
def update_mainsail_config(self, **kwargs) -> None:
self._run_update_routine(
"mainsail_config",
WebClientConfigSetupService("mainsail").update,
update_client_config,
self.mainsail_data,
)
def update_fluidd(self, **kwargs) -> None:
self._run_update_routine(
"fluidd",
WebClientSetupService("fluidd").update,
update_client,
self.fluidd_data,
)
def update_fluidd_config(self, **kwargs) -> None:
self._run_update_routine(
"fluidd_config",
WebClientConfigSetupService("fluidd").update,
update_client_config,
self.fluidd_data,
)
def update_klipperscreen(self, **kwargs) -> None:
@@ -252,16 +254,7 @@ class UpdateMenu(BaseMenu):
self._fetch_system_package_update_status()
def _fetch_system_package_update_status(self) -> None:
# Treat apt update failures as non-fatal here so the menu remains usable
# even when package metadata is unavailable. Dependency installation still
# fails fast elsewhere.
try:
update_system_package_lists(silent=True)
except RuntimeError as exc:
Logger.print_warn(
"Could not update the system package lists; "
f"system package status may be incomplete. ({exc})"
)
update_system_package_lists(silent=True)
self.packages = get_upgradable_packages()
self.package_count = len(self.packages)
@@ -1,213 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from core.services.backup_service import BackupService
class FakeKlipper:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.data_dir = Path(f"/tmp/klipper{suffix}_data")
self.cfg_file = self.data_dir.joinpath("printer.cfg")
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.data_dir = Path(f"/tmp/moonraker{suffix}_data")
self.cfg_file = self.data_dir.joinpath("moonraker.conf")
@pytest.fixture
def service(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BackupService:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
svc = BackupService()
return svc
class TestBackupFile:
def test_returns_false_when_source_does_not_exist(self, service: BackupService) -> None:
result = service.backup_file(source_path=Path("/does/not/exist.cfg"))
assert result is False
def test_returns_false_when_source_is_not_a_file(
self, service: BackupService, tmp_path: Path
) -> None:
directory = tmp_path / "directory"
directory.mkdir()
result = service.backup_file(source_path=directory)
assert result is False
def test_creates_backup_and_returns_true(
self, service: BackupService, tmp_path: Path
) -> None:
source = tmp_path / "printer.cfg"
source.write_text("config")
result = service.backup_file(source_path=source)
assert result is True
backups = list(service.backup_root.glob("*.cfg"))
assert len(backups) == 1
assert backups[0].read_text() == "config"
def test_skips_when_target_already_exists(
self, service: BackupService, tmp_path: Path
) -> None:
source = tmp_path / "printer.cfg"
source.write_text("config")
service.backup_root.mkdir(parents=True, exist_ok=True)
expected_name = f"printer_{service.timestamp}.cfg"
service.backup_root.joinpath(expected_name).touch()
result = service.backup_file(source_path=source)
assert result is True
def test_returns_false_on_copy_error(
self, service: BackupService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = tmp_path / "printer.cfg"
source.write_text("config")
monkeypatch.setattr(
"core.services.backup_service.shutil.copy2",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("copy failed")),
)
result = service.backup_file(source_path=source)
assert result is False
class TestBackupDirectory:
def test_returns_none_when_source_does_not_exist(
self, service: BackupService
) -> None:
result = service.backup_directory(
source_path=Path("/does/not/exist"), backup_name="config"
)
assert result is None
def test_returns_none_when_source_is_not_a_directory(
self, service: BackupService, tmp_path: Path
) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("data")
result = service.backup_directory(
source_path=file_path, backup_name="config"
)
assert result is None
def test_creates_timestamped_backup_directory(
self, service: BackupService, tmp_path: Path
) -> None:
source = tmp_path / "config"
source.mkdir()
source.joinpath("printer.cfg").write_text("data")
result = service.backup_directory(
source_path=source, backup_name="config"
)
assert result is not None
assert result.exists()
assert result.joinpath("printer.cfg").read_text() == "data"
def test_reuses_existing_backup_and_skips_existing_files(
self, service: BackupService, tmp_path: Path
) -> None:
source = tmp_path / "config"
source.mkdir()
source.joinpath("printer.cfg").write_text("new")
backup_dir = service.backup_root.joinpath(f"config_{service.timestamp}")
backup_dir.mkdir(parents=True)
backup_dir.joinpath("printer.cfg").write_text("old")
result = service.backup_directory(
source_path=source, backup_name="config"
)
assert result == backup_dir
assert result.joinpath("printer.cfg").read_text() == "old"
def test_returns_none_on_copy_error(
self, service: BackupService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = tmp_path / "config"
source.mkdir()
source.joinpath("file.cfg").write_text("data")
monkeypatch.setattr(
"core.services.backup_service.shutil.copytree",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("copytree failed")),
)
result = service.backup_directory(
source_path=source, backup_name="config"
)
assert result is None
class TestSpecificBackupMethods:
def test_backup_printer_cfg_backs_up_each_instance(
self, service: BackupService, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
instances = [FakeKlipper(""), FakeKlipper("a")]
for i in instances:
i.cfg_file.parent.mkdir(parents=True, exist_ok=True)
i.cfg_file.write_text("printer config")
monkeypatch.setattr(
"core.services.backup_service.get_instances", lambda model: instances
)
service.backup_printer_cfg()
backups = list(service.backup_root.rglob("printer*.cfg"))
assert len(backups) == 2
def test_backup_moonraker_conf_backs_up_each_instance(
self, service: BackupService, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
instances = [FakeMoonraker(""), FakeMoonraker("a")]
for i in instances:
i.cfg_file.parent.mkdir(parents=True, exist_ok=True)
i.cfg_file.write_text("moonraker config")
monkeypatch.setattr(
"core.services.backup_service.get_instances", lambda model: instances
)
service.backup_moonraker_conf()
backups = list(service.backup_root.rglob("moonraker*.conf"))
assert len(backups) == 2
def test_backup_printer_config_dir_falls_back_to_home_dirs(
self, service: BackupService, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(
"core.services.backup_service.get_instances", lambda model: []
)
printer_data = tmp_path / "printer_data"
printer_data.mkdir()
config_dir = printer_data / "config"
config_dir.mkdir()
config_dir.joinpath("printer.cfg").write_text("home config")
service.backup_printer_config_dir()
backups = list(service.backup_root.rglob("printer_data/config_*/printer.cfg"))
assert len(backups) == 1
def test_backup_printer_config_dir_returns_when_no_dirs_found(
self, service: BackupService, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"core.services.backup_service.get_instances", lambda model: []
)
# should not raise and should not create backups
service.backup_printer_config_dir()
assert not service.backup_root.exists()
@@ -1,61 +0,0 @@
from __future__ import annotations
from typing import Any, List
import pytest
from core.services.message_service import Message, MessageService
from core.types.color import Color
@pytest.fixture
def reset_message_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(MessageService, "_MessageService__cls_instance", None)
class TestMessage:
def test_default_message_is_empty(self) -> None:
msg = Message()
assert msg.title == ""
assert msg.text == []
assert msg.color == Color.WHITE
assert msg.centered is False
class TestMessageService:
def test_singleton_instance(self, reset_message_service) -> None:
a = MessageService()
b = MessageService()
assert a is b
def test_set_and_display_message(
self, reset_message_service, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
"core.services.message_service.Logger.print_dialog",
lambda **kwargs: calls.append(kwargs),
)
svc = MessageService()
msg = Message(title="Hello", text=["world"], color=Color.GREEN)
svc.set_message(msg)
svc.display_message()
assert calls[0]["custom_title"] == "Hello"
assert calls[0]["content"] == ["world"]
def test_display_without_message_does_nothing(
self, reset_message_service, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: List[Any] = []
monkeypatch.setattr(
"core.services.message_service.Logger.print_dialog",
lambda **kwargs: calls.append(kwargs),
)
svc = MessageService()
svc.display_message()
# no message set, so print_dialog should not have been invoked
assert calls == []
assert svc._MessageService__message is None
@@ -1,95 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from core.settings.kiauh_settings import KiauhSettings
DEFAULT_CFG_CONTENT = """\
[kiauh]
backup_before_update: False
[klipper]
repositories:
https://github.com/Klipper3d/klipper
[moonraker]
optional_speedups: True
repositories:
https://github.com/Arksine/moonraker
[mainsail]
port: 80
unstable_releases: False
[fluidd]
port: 80
unstable_releases: False
"""
@pytest.fixture
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(KiauhSettings, "_KiauhSettings__instance", None)
monkeypatch.setattr(KiauhSettings, "_KiauhSettings__initialized", False)
@pytest.fixture
def cfg_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reset_settings):
from core.settings import kiauh_settings as ks
default = tmp_path / "default.kiauh.cfg"
default.write_text(DEFAULT_CFG_CONTENT)
custom = tmp_path / "kiauh.cfg"
monkeypatch.setattr(ks, "DEFAULT_CFG", default)
monkeypatch.setattr(ks, "CUSTOM_CFG", custom)
return default, custom
class TestKiauhSettings:
def test_loads_default_when_custom_missing(self, cfg_paths) -> None:
settings = KiauhSettings()
assert settings.kiauh.backup_before_update is False
assert settings.mainsail.port == 80
assert settings.klipper.use_python_binary is None
def test_loads_custom_overrides(self, cfg_paths) -> None:
_, custom = cfg_paths
custom.write_text(
"[kiauh]\nbackup_before_update: True\n[mainsail]\nport: 8080\n"
)
settings = KiauhSettings()
assert settings.kiauh.backup_before_update is True
assert settings.mainsail.port == 8080
def test_save_writes_custom_config(self, cfg_paths) -> None:
_, custom = cfg_paths
settings = KiauhSettings()
settings.kiauh.backup_before_update = True
settings.save()
text = custom.read_text()
assert "backup_before_update: True" in text
def test_get_returns_value(self, cfg_paths) -> None:
settings = KiauhSettings()
assert settings.get("mainsail", "port") == 80
def test_missing_config_calls_kill(self, cfg_paths, monkeypatch) -> None:
from core.settings import kiauh_settings as ks
calls = []
def fake_kill(msg: str = "") -> None:
calls.append(msg)
raise SystemExit(1)
monkeypatch.setattr(ks, "DEFAULT_CFG", Path("/no/such/default.cfg"))
monkeypatch.setattr(ks, "CUSTOM_CFG", Path("/no/such/custom.cfg"))
monkeypatch.setattr(ks, "kill", fake_kill)
with pytest.raises(SystemExit):
KiauhSettings()
assert len(calls) == 1
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
from core.simple_config_parser.tests.utils import load_testdata_from_file
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import List
import pytest
from core.simple_config_parser.simple_config_parser import (
BlankLine,
CommentLine,
@@ -8,6 +8,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
BASE_DIR = Path(__file__).parent.parent.joinpath("assets")
@@ -7,6 +7,7 @@
# ======================================================================= #
import pytest
from core.simple_config_parser.simple_config_parser import (
MultiLineOption,
NoOptionError,
@@ -7,6 +7,7 @@
# ======================================================================= #
import pytest
from core.simple_config_parser.simple_config_parser import (
DuplicateSectionError,
)
@@ -8,6 +8,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
BASE_DIR = Path(__file__).parent.parent / "assets"
@@ -8,6 +8,7 @@
from pathlib import Path
import pytest
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
BASE_DIR = Path(__file__).parent.parent / "assets"
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
import pytest
from extensions.base_extension import BaseExtension
class ConcreteExtension(BaseExtension):
def install_extension(self, **kwargs) -> None:
pass
def remove_extension(self, **kwargs) -> None:
pass
class TestBaseExtension:
def test_concrete_subclass_can_be_instantiated(self) -> None:
ext = ConcreteExtension({"name": "test"})
assert ext.metadata["name"] == "test"
def test_update_extension_not_implemented(self) -> None:
ext = ConcreteExtension({"name": "test"})
with pytest.raises(NotImplementedError):
ext.update_extension()
def test_abstract_methods_enforced(self) -> None:
class PartialExtension(BaseExtension):
def install_extension(self, **kwargs) -> None:
pass
with pytest.raises(TypeError):
PartialExtension({"name": "test"})
+93
View File
@@ -0,0 +1,93 @@
# ======================================================================= #
# 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 os
import socket
from ipaddress import ip_address
from typing import List, Set
class UnsafeTargetError(Exception):
"""Raised when a live test target is considered unsafe."""
LOCAL_HOSTNAMES = {"localhost", "localhost.localdomain"}
LOCAL_ADDRESSES = {"127.0.0.1", "::1"}
def _local_interface_ips() -> Set[str]:
"""Return all IP addresses assigned to local network interfaces."""
ips: Set[str] = set()
try:
hostname = socket.gethostname()
infos = socket.getaddrinfo(hostname, None)
for info in infos:
addr = info[4][0]
ips.add(addr)
except socket.gaierror:
pass
return ips
def _resolve(host: str) -> List[str]:
"""Resolve a hostname to its IP addresses."""
try:
infos = socket.getaddrinfo(host, None)
return [info[4][0] for info in infos]
except socket.gaierror:
return []
def is_local_host(host: str) -> bool:
"""Return True if the host refers to the local machine."""
host_lower = host.lower().strip()
if host_lower in LOCAL_HOSTNAMES:
return True
if host_lower in LOCAL_ADDRESSES:
return True
if host_lower == socket.gethostname().lower():
return True
resolved = _resolve(host_lower)
local_ips = _local_interface_ips() | LOCAL_ADDRESSES
for addr in resolved:
if addr in local_ips:
return True
try:
if ip_address(addr).is_loopback:
return True
except ValueError:
pass
return False
def assert_safe_to_run(vm) -> None:
"""Multi-layer safety check before running live tests against a VM."""
if os.environ.get("KIAUH_LIVE_ALLOW") != "1":
raise UnsafeTargetError(
"Live tests disabled. Set KIAUH_LIVE_ALLOW=1 to enable."
)
if not vm.host:
raise UnsafeTargetError("VM host is empty")
if is_local_host(vm.host):
raise UnsafeTargetError(
f"Refusing to run live tests against local host: {vm.host}"
)
if os.environ.get("KIAUH_LIVE_TARGET_HOST") != vm.host:
raise UnsafeTargetError(
"KIAUH_LIVE_TARGET_HOST must match the selected VM host"
)
+90
View File
@@ -0,0 +1,90 @@
# ======================================================================= #
# 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 os
from dataclasses import dataclass
from pathlib import Path
from typing import List
import yaml
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parents[2].joinpath(".env"))
except ImportError:
load_dotenv = None
DEFAULT_INVENTORY_PATH = Path(__file__).parent.joinpath("inventory.yaml")
@dataclass
class VM:
name: str
host: str
user: str
key_file: str
os: str
domain: str | None = None
snapshot: str | None = None
class InventoryError(Exception):
pass
def _env_var_name(vm_name: str, suffix: str) -> str:
safe_name = vm_name.replace("-", "_").upper()
return f"KIAUH_LIVE_{safe_name}_{suffix.upper()}"
def load_inventory(path: Path | None = None) -> List[VM]:
inventory_path = Path(path or os.environ.get("KIAUH_LIVE_INVENTORY", DEFAULT_INVENTORY_PATH))
if not inventory_path.exists():
raise InventoryError(f"Inventory file not found: {inventory_path}")
data = yaml.safe_load(inventory_path.read_text())
if not data or "vms" not in data:
raise InventoryError("Inventory must contain a 'vms' list")
vms = []
for item in data["vms"]:
for required in ("name", "user", "os"):
if required not in item:
raise InventoryError(f"VM '{item.get('name', '?')}' missing '{required}'")
name = item["name"]
host = os.environ.get(_env_var_name(name, "host"), item.get("host"))
key_file = os.environ.get(_env_var_name(name, "key_file"), item.get("key_file"))
if not host:
raise InventoryError(f"VM '{name}' missing 'host' (inventory or env)")
if not key_file:
raise InventoryError(f"VM '{name}' missing 'key_file' (inventory or env)")
vms.append(
VM(
name=name,
host=host,
user=item["user"],
key_file=key_file,
os=item["os"],
domain=item.get("domain"),
snapshot=item.get("snapshot"),
)
)
return vms
def get_vm(name: str, path: Path | None = None) -> VM:
for vm in load_inventory(path):
if vm.name == name:
return vm
raise InventoryError(f"VM '{name}' not found in inventory")
+11
View File
@@ -0,0 +1,11 @@
vms:
- name: debian12-kiauh
# Override host/key_file via environment variables or a .env file.
# KIAUH_LIVE_DEBIAN12_KIAUH_HOST
# KIAUH_LIVE_DEBIAN12_KIAUH_KEY_FILE
host: null
user: kiauh
key_file: null
os: debian-12
domain: debian12-kiauh
snapshot: clean
+93
View File
@@ -0,0 +1,93 @@
# ======================================================================= #
# 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 subprocess
from pathlib import Path
from typing import List
import paramiko
import testinfra
from live.guards import assert_safe_to_run, is_local_host
from live.inventory import VM
class LiveRunnerError(Exception):
pass
class LiveRunner:
"""SSH runner for live VM tests."""
def __init__(self, vm: VM) -> None:
assert_safe_to_run(vm)
self.vm = vm
self._ssh: paramiko.SSHClient | None = None
def connect(self) -> paramiko.SSHClient:
if self._ssh is not None:
return self._ssh
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
key_file = Path(self.vm.key_file).expanduser()
client.connect(
self.vm.host,
username=self.vm.user,
key_filename=str(key_file),
look_for_keys=False,
timeout=30,
)
self._ssh = client
return client
def run(self, command: List[str], timeout: int = 120) -> subprocess.CompletedProcess:
client = self.connect()
cmd = " ".join(command) if isinstance(command, list) else command
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
rc = stdout.channel.recv_exit_status()
return subprocess.CompletedProcess(
args=command,
returncode=rc,
stdout=stdout.read().decode("utf-8", errors="replace"),
stderr=stderr.read().decode("utf-8", errors="replace"),
)
def get_host(self) -> testinfra.host.Host:
"""Return a Testinfra host for assertions."""
key_file = Path(self.vm.key_file).expanduser()
return testinfra.get_host(
f"paramiko://{self.vm.user}@{self.vm.host}",
ssh_identity_file=str(key_file),
)
def close(self) -> None:
if self._ssh is not None:
self._ssh.close()
self._ssh = None
def revert_vm_snapshot(vm: VM) -> None:
"""Revert a VM to the configured snapshot before a scenario."""
if not vm.domain or not vm.snapshot:
raise LiveRunnerError("VM inventory missing domain or snapshot")
if is_local_host(vm.host):
raise LiveRunnerError("Refusing to revert a local VM snapshot")
result = subprocess.run(
["virsh", "snapshot-revert", vm.domain, vm.snapshot, "--running"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise LiveRunnerError(
f"Failed to revert snapshot: {result.stderr.strip() or result.stdout.strip()}"
)
+85
View File
@@ -0,0 +1,85 @@
# ======================================================================= #
# 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
from pathlib import Path
from typing import Any, Dict, List
import yaml
SCENARIOS_DIR = Path(__file__).parent.joinpath("scenarios")
class ScenarioError(Exception):
pass
def load_scenarios(directory: Path | None = None) -> List[Dict[str, Any]]:
path = Path(directory or SCENARIOS_DIR)
if not path.exists():
return []
scenarios = []
for file in sorted(path.glob("*.yaml")):
data = yaml.safe_load(file.read_text())
if not isinstance(data, dict):
raise ScenarioError(f"Scenario {file.name} is not a mapping")
data.setdefault("file", str(file))
scenarios.append(data)
return scenarios
def assert_expected(host, expected: List[Dict[str, Any]]) -> None:
"""Evaluate expected outcomes using Testinfra assertions."""
failures = []
for item in expected:
try:
_assert_item(host, item)
except AssertionError as e:
failures.append(f"{item}: {e}")
if failures:
raise AssertionError("\n".join(failures))
def _assert_item(host, item: Dict[str, Any]) -> None:
assertion_type = item.get("type")
if assertion_type == "service":
service = host.service(item["name"])
state = item.get("state")
if state == "running":
assert service.is_running, f"service {item['name']} is not running"
elif state == "enabled":
assert service.is_enabled, f"service {item['name']} is not enabled"
elif assertion_type == "file":
file = host.file(item["path"])
if item.get("exists", True):
assert file.exists, f"file {item['path']} does not exist"
else:
assert not file.exists, f"file {item['path']} should not exist"
elif assertion_type == "package":
pkg = host.package(item["name"])
assert pkg.is_installed, f"package {item['name']} is not installed"
elif assertion_type == "port":
socket = host.socket(f"tcp://{item['address']}:{item['port']}")
assert socket.is_listening, f"port {item['port']} is not listening"
elif assertion_type == "command":
result = host.run(item["command"])
assert result.rc == item.get("returncode", 0), (
f"command {item['command']} returned {result.rc}: {result.stderr}"
)
else:
raise ScenarioError(f"Unknown assertion type: {assertion_type}")
+24
View File
@@ -0,0 +1,24 @@
name: Backup and restore Klipper data on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["mkdir", "-p", "/home/kiauh/backups"]
timeout: 10
- command: ["tar", "-czf", "/home/kiauh/backups/printer_data.tar.gz", "-C", "/home/kiauh", "printer_data"]
timeout: 60
- command: ["rm", "-rf", "/home/kiauh/printer_data"]
timeout: 30
- command: ["tar", "-xzf", "/home/kiauh/backups/printer_data.tar.gz", "-C", "/home/kiauh"]
timeout: 60
expected:
- type: file
path: /home/kiauh/backups/printer_data.tar.gz
exists: true
- type: file
path: /home/kiauh/printer_data
exists: true
- type: service
name: klipper.service
state: running
+18
View File
@@ -0,0 +1,18 @@
name: Install Klipper on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["sudo", "systemctl", "is-active", "klipper.service"]
timeout: 30
expected:
- type: service
name: klipper.service
state: running
- type: file
path: /home/kiauh/klipper/klipper
exists: true
- type: command
command: systemctl is-enabled klipper.service
returncode: 0
+18
View File
@@ -0,0 +1,18 @@
name: Remove Klipper on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["kiauh", "remove", "klipper", "--service", "--dir", "--env"]
timeout: 120
expected:
- type: file
path: /etc/systemd/system/klipper.service
exists: false
- type: file
path: /home/kiauh/klipper
exists: false
- type: file
path: /home/kiauh/klippy-env
exists: false
+15
View File
@@ -0,0 +1,15 @@
name: Update Klipper on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["kiauh", "update", "klipper"]
timeout: 300
expected:
- type: service
name: klipper.service
state: running
- type: command
command: test -d /home/kiauh/klipper/.git
returncode: 0
@@ -0,0 +1,15 @@
name: Install Moonraker on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["kiauh", "install", "moonraker"]
timeout: 600
expected:
- type: service
name: moonraker.service
state: running
- type: file
path: /home/kiauh/moonraker/moonraker
exists: true
@@ -0,0 +1,17 @@
name: Remove Moonraker on Debian 12
vm: debian12-kiauh
os: debian-12
steps:
- command: ["kiauh", "install", "klipper", "--count", "1"]
timeout: 600
- command: ["kiauh", "install", "moonraker"]
timeout: 600
- command: ["kiauh", "remove", "moonraker", "--service", "--dir", "--env"]
timeout: 120
expected:
- type: file
path: /etc/systemd/system/moonraker.service
exists: false
- type: file
path: /home/kiauh/moonraker
exists: false
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any, Dict, Generator, List
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from live.inventory import VM, get_vm
from live.runner import LiveRunner, revert_vm_snapshot
from live.scenarios import load_scenarios
@pytest.fixture(autouse=True)
def silence_logger(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"print_info",
"print_ok",
"print_warn",
"print_error",
"print_status",
"print_dialog",
):
monkeypatch.setattr(f"core.logger.Logger.{name}", lambda *a, **k: None)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"live: tests that run against a real VM (isolated, never local)",
)
def pytest_collection_modifyitems(config: pytest.Config, items: List[pytest.Item]) -> None:
skip_live = pytest.mark.skip(reason="live tests skipped by default; use -m live")
for item in items:
if item.get_closest_marker("live") and not config.getoption("-m"):
item.add_marker(skip_live)
@pytest.fixture(scope="session")
def live_vm() -> VM:
"""Single VM used for live tests."""
vm_name = os.environ.get("KIAUH_LIVE_VM", "debian12-kiauh")
return get_vm(vm_name)
@pytest.fixture(scope="function")
def live_runner(live_vm: VM) -> Generator[LiveRunner, None, None]:
runner = LiveRunner(live_vm)
yield runner
runner.close()
@pytest.fixture(scope="function")
def fresh_vm(live_vm: VM) -> VM:
"""Revert the VM to its clean snapshot before each scenario."""
revert_vm_snapshot(live_vm)
return live_vm
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "scenario" in metafunc.fixturenames:
scenarios = load_scenarios()
metafunc.parametrize(
"scenario",
scenarios,
ids=[s.get("name", s.get("file", "unknown")) for s in scenarios],
)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import socket
import pytest
from live.guards import UnsafeTargetError, assert_safe_to_run, is_local_host
from live.inventory import VM
class TestIsLocalHost:
def test_localhost_is_local(self) -> None:
assert is_local_host("localhost") is True
def test_127_is_local(self) -> None:
assert is_local_host("127.0.0.1") is True
assert is_local_host("::1") is True
def test_current_hostname_is_local(self) -> None:
assert is_local_host(socket.gethostname()) is True
def test_remote_host_is_not_local(self) -> None:
assert is_local_host("192.168.122.10") is False
assert is_local_host("example.com") is False
class TestAssertSafeToRun:
def test_requires_live_allow_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("KIAUH_LIVE_ALLOW", raising=False)
vm = VM(name="vm", host="192.168.122.10", user="u", key_file="k", os="debian-12")
with pytest.raises(UnsafeTargetError, match="disabled"):
assert_safe_to_run(vm)
def test_blocks_localhost(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KIAUH_LIVE_ALLOW", "1")
monkeypatch.setenv("KIAUH_LIVE_TARGET_HOST", "localhost")
vm = VM(name="vm", host="localhost", user="u", key_file="k", os="debian-12")
with pytest.raises(UnsafeTargetError, match="local host"):
assert_safe_to_run(vm)
def test_requires_matching_target_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KIAUH_LIVE_ALLOW", "1")
monkeypatch.setenv("KIAUH_LIVE_TARGET_HOST", "192.168.122.10")
vm = VM(name="vm", host="192.168.122.11", user="u", key_file="k", os="debian-12")
with pytest.raises(UnsafeTargetError, match="must match"):
assert_safe_to_run(vm)
def test_passes_for_safe_remote_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KIAUH_LIVE_ALLOW", "1")
monkeypatch.setenv("KIAUH_LIVE_TARGET_HOST", "192.168.122.10")
vm = VM(name="vm", host="192.168.122.10", user="u", key_file="k", os="debian-12")
assert assert_safe_to_run(vm) is None
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
import pytest
from live.inventory import InventoryError, VM, load_inventory
class TestLoadInventory:
def test_loads_vm_from_inventory(self, tmp_path: Path) -> None:
inv = tmp_path / "inv.yaml"
inv.write_text(
"vms:\n"
" - name: debian12\n"
" host: 10.0.0.5\n"
" user: kiauh\n"
" key_file: /key\n"
" os: debian-12\n"
)
vms = load_inventory(inv)
assert len(vms) == 1
assert vms[0].host == "10.0.0.5"
assert vms[0].key_file == "/key"
def test_env_overrides_host_and_key_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("KIAUH_LIVE_DEBIAN12_HOST", "192.168.1.50")
monkeypatch.setenv("KIAUH_LIVE_DEBIAN12_KEY_FILE", "/secret/key")
inv = tmp_path / "inv.yaml"
inv.write_text(
"vms:\n"
" - name: debian12\n"
" host: 10.0.0.5\n"
" user: kiauh\n"
" key_file: /key\n"
" os: debian-12\n"
)
vms = load_inventory(inv)
assert vms[0].host == "192.168.1.50"
assert vms[0].key_file == "/secret/key"
def test_missing_host_raises(self, tmp_path: Path) -> None:
inv = tmp_path / "inv.yaml"
inv.write_text(
"vms:\n"
" - name: debian12\n"
" user: kiauh\n"
" key_file: /key\n"
" os: debian-12\n"
)
with pytest.raises(InventoryError, match="missing 'host'"):
load_inventory(inv)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from live.inventory import VM
from live.runner import LiveRunner
from live.scenarios import assert_expected
@pytest.mark.live
def test_scenario(fresh_vm: VM, live_runner: LiveRunner, scenario: Dict[str, Any]) -> None:
"""Run a live scenario against the VM and verify expected outcomes."""
steps: List[Dict[str, Any]] = scenario.get("steps", [])
expected = scenario.get("expected", [])
for step in steps:
command = step["command"]
timeout = step.get("timeout", 120)
result = live_runner.run(command, timeout=timeout)
assert result.returncode == 0, (
f"Step failed: {' '.join(command)}\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
host = live_runner.get_host()
assert_expected(host, expected)
+6 -11
View File
@@ -9,7 +9,6 @@
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
@@ -22,16 +21,12 @@ def ensure_encoding() -> None:
def main() -> None:
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)
try:
KiauhSettings()
ensure_encoding()
MainMenu().run()
except KeyboardInterrupt:
Logger.print_ok("\nHappy printing!\n", prefix=False)
if __name__ == "__main__":
View File
View File
-178
View File
@@ -1,178 +0,0 @@
# ======================================================================= #
# Test-only backends. Not imported by production code. #
# ======================================================================= #
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Sequence, Tuple
class FakeCommandRunner:
"""Command runner for tests. Records calls and returns scripted responses.
By default, running a command that was not explicitly scripted raises an
error so missing mocks are caught during development. Pass
``strict=False`` to restore the legacy "default success" behavior.
"""
def __init__(
self,
responses: Dict[Tuple[str, ...], subprocess.CompletedProcess] | None = None,
*,
strict: bool = True,
) -> None:
self.calls: List[Tuple[str | Sequence[str], Dict[str, Any]]] = []
self.responses = responses or {}
self.strict = strict
@staticmethod
def _key(cmd: str | Sequence[str]) -> Tuple[str, ...]:
if isinstance(cmd, str):
return (cmd,)
return tuple(str(c) for c in cmd)
def _make_response(
self, cmd: str | Sequence[str], returncode: int = 0
) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=cmd,
returncode=returncode,
stdout="",
stderr="",
)
def _unscripted(self, cmd: str | Sequence[str]) -> subprocess.CompletedProcess:
if self.strict:
raise RuntimeError(f"Unscripted command: {cmd}")
return self._make_response(cmd)
def run(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.CompletedProcess:
self.calls.append((cmd, kwargs))
key = self._key(cmd)
if key in self.responses:
return self.responses[key]
return self._unscripted(cmd)
def check_output(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> str | bytes:
self.calls.append((cmd, kwargs))
key = self._key(cmd)
if key in self.responses:
return self.responses[key].stdout # type: ignore[no-any-return]
if self.strict:
raise RuntimeError(f"Unscripted command: {cmd}")
return ""
def call(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> int:
self.calls.append((cmd, kwargs))
key = self._key(cmd)
if key in self.responses:
return self.responses[key].returncode
if self.strict:
raise RuntimeError(f"Unscripted command: {cmd}")
return 0
def popen(
self,
cmd: str | Sequence[str],
**kwargs: Any,
) -> subprocess.Popen:
raise NotImplementedError("FakeCommandRunner.popen is not implemented")
class FakeFilesystemBackend:
"""In-memory filesystem backend for tests."""
def __init__(self) -> None:
self.dirs: set[str] = set()
self.files: Dict[str, str] = {}
self.symlinks: Dict[str, str] = {}
self._home: Path = Path("/home/test")
def _path(self, path: Path) -> str:
return str(Path(path).resolve())
def exists(self, path: Path) -> bool:
key = self._path(path)
return key in self.dirs or key in self.files or key in self.symlinks
def is_dir(self, path: Path) -> bool:
return self._path(path) in self.dirs
def is_file(self, path: Path) -> bool:
return self._path(path) in self.files
def is_symlink(self, path: Path) -> bool:
return self._path(path) in self.symlinks
def mkdir(
self, path: Path, *, parents: bool = False, exist_ok: bool = False
) -> None:
key = self._path(path)
if key in self.files and not exist_ok:
raise FileExistsError(key)
if key in self.dirs and not exist_ok:
raise FileExistsError(key)
if parents:
for parent in reversed(Path(key).parents):
self.dirs.add(str(parent))
self.dirs.add(key)
def unlink(self, path: Path) -> None:
key = self._path(path)
if key in self.files:
del self.files[key]
elif key in self.symlinks:
del self.symlinks[key]
else:
raise FileNotFoundError(key)
def rmtree(self, path: Path) -> None:
key = self._path(path)
if key not in self.dirs:
raise FileNotFoundError(key)
prefix = key + "/"
self.dirs = {d for d in self.dirs if not (d == key or d.startswith(prefix))}
self.files = {k: v for k, v in self.files.items() if not k.startswith(prefix)}
self.symlinks = {
k: v for k, v in self.symlinks.items() if not k.startswith(prefix)
}
def read_text(self, path: Path) -> str:
key = self._path(path)
if key not in self.files:
raise FileNotFoundError(key)
return self.files[key]
def write_text(self, path: Path, content: str) -> None:
key = self._path(path)
self.files[key] = content
self.dirs.discard(key)
def copy(self, source: Path, target: Path) -> None:
content = self.read_text(source)
self.write_text(target, content)
def home(self) -> Path:
return self._home
def add_dir(self, path: Path) -> None:
self.dirs.add(self._path(path))
def add_file(self, path: Path, content: str = "") -> None:
self.files[self._path(path)] = content
def add_symlink(self, path: Path, target: Path) -> None:
self.symlinks[self._path(path)] = str(target)
-98
View File
@@ -1,98 +0,0 @@
# ======================================================================= #
# 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
from typing import List
import main as main_module
import pytest
class _FakeMainMenu:
"""Minimal stand-in for ``core.menus.main_menu.MainMenu``."""
instances: List["_FakeMainMenu"] = []
def __init__(self) -> None:
self._run = False
type(self).instances.append(self)
def run(self) -> None:
self._run = True
@classmethod
def reset(cls) -> None:
cls.instances = []
@pytest.fixture(autouse=True)
def _reset_fake_menu() -> None:
_FakeMainMenu.reset()
yield
_FakeMainMenu.reset()
def _patch_tui_seeds(monkeypatch: pytest.MonkeyPatch) -> None:
"""Neutralise the heavyweight side-effects triggered when launching the TUI."""
monkeypatch.setattr(main_module, "KiauhSettings", lambda: None)
monkeypatch.setattr(main_module, "ensure_encoding", lambda: None)
monkeypatch.setattr(main_module, "MainMenu", _FakeMainMenu)
class TestMainDispatch:
def test_no_command_launches_tui(self, monkeypatch: pytest.MonkeyPatch) -> None:
# rc == -1 means "fall back to the TUI": ``MainMenu().run()`` is called.
monkeypatch.setattr(main_module, "run_cli", lambda: -1)
_patch_tui_seeds(monkeypatch)
main_module.main()
assert _FakeMainMenu.instances
assert all(m._run for m in _FakeMainMenu.instances)
def test_cli_success_returns_cleanly(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# rc == 0 means the CLI succeeded; the TUI must NOT start and main must
# NOT call sys.exit.
monkeypatch.setattr(main_module, "run_cli", lambda: 0)
_patch_tui_seeds(monkeypatch)
main_module.main() # must not raise SystemExit
assert _FakeMainMenu.instances == []
def test_cli_failure_exits_nonzero(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# rc > 0 means the CLI reported a failure; main must propagate via sys.exit.
monkeypatch.setattr(main_module, "run_cli", lambda: 2)
_patch_tui_seeds(monkeypatch)
with pytest.raises(SystemExit) as exc:
main_module.main()
assert exc.value.code == 2
assert _FakeMainMenu.instances == []
def test_tui_keyboard_interrupt_is_absorbed(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A Ctrl-C while the TUI runs must be caught and printed friendly
# instead of crashing with a traceback.
class _InterruptingMenu(_FakeMainMenu):
def run(self) -> None:
raise KeyboardInterrupt()
monkeypatch.setattr(main_module, "run_cli", lambda: -1)
monkeypatch.setattr(main_module, "KiauhSettings", lambda: None)
monkeypatch.setattr(main_module, "ensure_encoding", lambda: None)
monkeypatch.setattr(main_module, "MainMenu", _InterruptingMenu)
main_module.main() # must not raise; KeyboardInterrupt is absorbed

Some files were not shown because too many files have changed in this diff Show More