mirror of
https://github.com/dw-0/kiauh.git
synced 2026-08-03 04:47:56 +05:00
Compare commits
24
Commits
v6.2.0
...
a78a6c76f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78a6c76f5 | ||
|
|
b6a08ce4e5 | ||
|
|
a3f9ee1faf | ||
|
|
477d737489 | ||
|
|
9b93450d98 | ||
|
|
adf3e292fb | ||
|
|
58e3eb9f06 | ||
|
|
09dd8298f7 | ||
|
|
93e7bb7212 | ||
|
|
e4eb36d72f | ||
|
|
8b9d172805 | ||
|
|
6aa170fd68 | ||
|
|
861cb2bff4 | ||
|
|
7b5522ac94 | ||
|
|
a42b730688 | ||
|
|
77d6c87aa0 | ||
|
|
b893ff14f7 | ||
|
|
43b0994ac5 | ||
|
|
040edc1d4f | ||
|
|
750dba1dbe | ||
|
|
6f4b471008 | ||
|
|
5077765fd6 | ||
|
|
9f97ae6c2a | ||
|
|
b90a8f13b1 |
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
.jupyter
|
.jupyter
|
||||||
*.ipynb
|
*.ipynb
|
||||||
*.ipynb_checkpoints
|
*.ipynb_checkpoints
|
||||||
@@ -10,5 +12,7 @@ __pycache__
|
|||||||
.venv
|
.venv
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
*.iml
|
*.iml
|
||||||
|
*.egg-info
|
||||||
|
.coverage
|
||||||
kiauh.cfg
|
kiauh.cfg
|
||||||
klipper_repos.txt
|
klipper_repos.txt
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
# AGENTS.md - KIAUH Development Guide
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
KIAUH (Klipper Installation And Update Helper) is a Python-based installation script for Klipper 3D printer firmware and related components written in Python 3.8+.
|
|
||||||
|
|
||||||
## Running KIAUH
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./kiauh.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Must NOT run as root. The script will exit if EUID is 0.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dev dependencies
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
# Lint (ruff)
|
|
||||||
ruff check .
|
|
||||||
|
|
||||||
# Format
|
|
||||||
ruff format .
|
|
||||||
|
|
||||||
# Typecheck
|
|
||||||
mypy kiauh
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
pytest kiauh/core/simple_config_parser/tests/public_api/test_options_api.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- New tests should be placed near their corresponding components/modules (e.g., `kiauh/components/klipper/*/test_*.py`)
|
|
||||||
- Always use a `tests/` subdirectory
|
|
||||||
- Existing pytest setup in `kiauh/core/simple_config_parser/tests/` serves as reference
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `kiauh.sh` - Bash entry point, sets PYTHONPATH and calls main.py
|
|
||||||
- `kiauh/main.py` - Python entry point
|
|
||||||
- `kiauh/core/` - Core functionality (menus, services, settings, types)
|
|
||||||
- `kiauh/components/` - Klipper components (klipper, moonraker, webui_client, etc.)
|
|
||||||
- `kiauh/extensions/` - Extension system for optional addons (obico, octoprint, spoolman, etc.)
|
|
||||||
- `kiauh/core/simple_config_parser/` - Custom INI-style config parser for Klipper configs
|
|
||||||
- `kiauh/core/simple_config_parser/src/simple_config_parser/` - Submodule (git subtree)
|
|
||||||
|
|
||||||
## Key Quirks
|
|
||||||
|
|
||||||
1. **Python version:** Requires Python 3.8+ (checked in kiauh.sh)
|
|
||||||
2. **Config files:** KIAUH uses `kiauh.cfg` in project root (not .ini format - it's parsed by simple_config_parser)
|
|
||||||
3. **Submodule:** `kiauh/core/simple_config_parser/` is a git subtree, not a submodule
|
|
||||||
4. **Branch check:** KIAUH only checks for updates on master branch (not develop)
|
|
||||||
5. **Target:** Designed to run on Raspberry Pi OS / Debian-based distros
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- 4-space indentation
|
|
||||||
- 88 character line length
|
|
||||||
- Double quotes
|
|
||||||
- LF line endings
|
|
||||||
- Type hints required (mypy checks)
|
|
||||||
- Ruff with I (isort) enabled
|
|
||||||
@@ -122,9 +122,9 @@ changes!**
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<h2 align="center">🌐 Sources & Further Information</h2>
|
<h2 align="center">⚙️ Core Components ⚙️</h2>
|
||||||
|
|
||||||
<table align="center">
|
<table align="center" style="text-align: center;">
|
||||||
<tr>
|
<tr>
|
||||||
<th><h3><a href="https://github.com/Klipper3d/klipper">Klipper</a></h3></th>
|
<th><h3><a href="https://github.com/Klipper3d/klipper">Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Arksine/moonraker">Moonraker</a></h3></th>
|
<th><h3><a href="https://github.com/Arksine/moonraker">Moonraker</a></h3></th>
|
||||||
@@ -140,67 +140,88 @@ changes!**
|
|||||||
<th>by <a href="https://github.com/Arksine">Arksine</a></th>
|
<th>by <a href="https://github.com/Arksine">Arksine</a></th>
|
||||||
<th>by <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
|
<th>by <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th><h3><a href="https://github.com/fluidd-core/fluidd">Fluidd</a></h3></th>
|
<th><h3><a href="https://github.com/fluidd-core/fluidd">Fluidd</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/KlipperScreen/KlipperScreen">KlipperScreen</a></h3></th>
|
<th><h3><a href="https://github.com/KlipperScreen/KlipperScreen">KlipperScreen</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/31575189?v=4" alt="jordanruthe avatar" height="64"></th>
|
<th><img src="https://avatars.githubusercontent.com/KlipperScreen?v=4" alt="KlipperScreen Logo" height="64"></th>
|
||||||
<th><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>by <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
<th>by <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
||||||
<th>by <a href="https://github.com/alfrix">alfrix</a></th>
|
<th>by <a href="https://github.com/alfrix">alfrix</a></th>
|
||||||
<th>by <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 align="center">🧩 Community Extensions 🧩</h2>
|
||||||
|
|
||||||
|
<table align="center" style="text-align: center;">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/nlef/moonraker-telegram-bot">Moonraker-Telegram-Bot</a></h3></th>
|
<th><h3><a href="https://github.com/nlef/moonraker-telegram-bot">Moonraker-Telegram-Bot</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Kragrathea/pgcode">PrettyGCode for Klipper</a></h3></th>
|
<th><h3><a href="https://github.com/Kragrathea/pgcode">PrettyGCode for Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></th>
|
<th><a href="https://github.com/OctoPrint/OctoPrint"><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></a></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></th>
|
<th><a href="https://github.com/nlef/moonraker-telegram-bot"><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></a></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></th>
|
<th><a href="https://github.com/Kragrathea/pgcode"><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></a></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>by <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
||||||
<th>by <a href="https://github.com/nlef">nlef</a></th>
|
<th>by <a href="https://github.com/nlef">nlef</a></th>
|
||||||
<th>by <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
<th>by <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
||||||
<th>by <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</a></h3></th>
|
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</a></h3></th>
|
||||||
<th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
|
<th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><a href="https://github.com/TheSpaghettiDetective/moonraker-obico"><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></a></th>
|
||||||
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="Mobileraker Logo" height="64"></a></th>
|
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="Mobileraker Logo" height="64"></a></th>
|
||||||
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
|
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
|
||||||
<th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>by <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
||||||
<th>by <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
<th>by <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
||||||
<th>by <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
<th>by <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
||||||
<th>by <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/staubgeborener/klipper-backup">Klipper-Backup</a></h3></th>
|
<th><h3><a href="https://github.com/staubgeborener/klipper-backup">Klipper-Backup</a></h3></th>
|
||||||
<th><h3><a href="https://simplyprint.io/">SimplyPrint for Klipper</a></h3></th>
|
<th><h3><a href="https://simplyprint.io/">SimplyPrint for Klipper</a></h3></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
||||||
<th><a href="https://github.com/staubgeborener/klipper-backup"><img src="https://avatars.githubusercontent.com/u/28908603?v=4" alt="Staubgeroner Avatar" height="64"></a></th>
|
<th><a href="https://github.com/staubgeborener/klipper-backup"><img src="https://avatars.githubusercontent.com/u/28908603?v=4" alt="Staubgeroner Avatar" height="64"></a></th>
|
||||||
<th><a href="https://github.com/SimplyPrint"><img src="https://avatars.githubusercontent.com/u/64896552?s=200&v=4" alt="" height="64"></a></th>
|
<th><a href="https://github.com/SimplyPrint"><img src="https://avatars.githubusercontent.com/u/64896552?s=200&v=4" alt="SimplyPrint Logo" height="64"></a></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>by <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
||||||
<th>by <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
<th>by <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
||||||
<th>by <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
<th>by <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/CodeMasterCody3D/DroidKlipp">DroidKlipp</a></h3></th>
|
||||||
|
<th><h3><a href="https://github.com/PEEKYPAUL/Moongate">Moongate</a></h3></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><a href="https://github.com/CodeMasterCody3D/DroidKlipp"><img src="https://raw.githubusercontent.com/CodeMasterCody3D/DroidKlipp/main/logo.png" alt="DroidKlipp Logo" height="64"></a></th>
|
||||||
|
<th><a href="https://github.com/PEEKYPAUL/Moongate"><img src="https://raw.githubusercontent.com/PEEKYPAUL/Moongate/master/docs/moongate-icon.png" alt="Moongate Logo" height="64"></a></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>by <a href="https://github.com/CodeMasterCody3D">CodeMasterCody3D</a></th>
|
||||||
|
<th>by <a href="https://github.com/PEEKYPAUL">PEEKYPAUL</a></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
@@ -229,13 +250,3 @@ changes!**
|
|||||||
a [Ko-fi](https://ko-fi.com/dw__0) !
|
a [Ko-fi](https://ko-fi.com/dw__0) !
|
||||||
* Last but not least: Thank you to all contributors and members of the Klipper
|
* Last but not least: Thank you to all contributors and members of the Klipper
|
||||||
Community who like and share this project!
|
Community who like and share this project!
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 align="center">A special thank you to JetBrains for sponsoring this project
|
|
||||||
with their incredible software!</h4>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.jetbrains.com/community/opensource/#support" target="_blank">
|
|
||||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo." height="128">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|||||||
+51
-33
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a>
|
<a>
|
||||||
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/kiauh.png" alt="KIAUH logo" height="181">
|
<img src="docs/assets/logo-large.png" alt="KIAUH logo" height="181">
|
||||||
<h1 align="center">Klipper Installation And Update Helper</h1>
|
<h1 align="center">Klipper Installation And Update Helper</h1>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -34,13 +34,13 @@ KIAUH 是一个帮助您在 Linux 系统上安装 Klipper 的脚本工具,
|
|||||||
选择 `Choose OS -> Raspberry Pi OS (other)`:
|
选择 `Choose OS -> Raspberry Pi OS (other)`:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager1.png" alt="KIAUH logo" height="350">
|
<img src="docs/assets/rpi_imager1.png" alt="KIAUH logo" height="350">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
* 然后选择 `Raspberry Pi OS Lite (32位)` (或如果您想使用64位版本):
|
* 然后选择 `Raspberry Pi OS Lite (32位)` (或如果您想使用64位版本):
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager2.png" alt="KIAUH logo" height="350">
|
<img src="docs/assets/rpi_imager2.png" alt="KIAUH logo" height="350">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
* 返回 Raspberry Pi Imager 主界面,选择对应的 SD 卡作为写入目标。
|
* 返回 Raspberry Pi Imager 主界面,选择对应的 SD 卡作为写入目标。
|
||||||
@@ -99,9 +99,9 @@ cd ~ && git clone https://github.com/dw-0/kiauh.git
|
|||||||
您会被要求输入 sudo 密码。
|
您会被要求输入 sudo 密码。
|
||||||
因为有几个功能需要 sudo 权限。
|
因为有几个功能需要 sudo 权限。
|
||||||
|
|
||||||
## 🌐 相关资源与更多信息
|
<h2 align="center">⚙️ 核心组件 ⚙️</h2>
|
||||||
|
|
||||||
<table align="center">
|
<table align="center" style="text-align: center;">
|
||||||
<tr>
|
<tr>
|
||||||
<th><h3><a href="https://github.com/Klipper3d/klipper">Klipper</a></h3></th>
|
<th><h3><a href="https://github.com/Klipper3d/klipper">Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Arksine/moonraker">Moonraker</a></h3></th>
|
<th><h3><a href="https://github.com/Arksine/moonraker">Moonraker</a></h3></th>
|
||||||
@@ -117,70 +117,93 @@ cd ~ && git clone https://github.com/dw-0/kiauh.git
|
|||||||
<th>由 <a href="https://github.com/Arksine">Arksine</a></th>
|
<th>由 <a href="https://github.com/Arksine">Arksine</a></th>
|
||||||
<th>由 <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
|
<th>由 <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<th><h3><a href="https://github.com/fluidd-core/fluidd">Fluidd</a></h3></th>
|
<th><h3><a href="https://github.com/fluidd-core/fluidd">Fluidd</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/jordanruthe/KlipperScreen">KlipperScreen</a></h3></th>
|
<th><h3><a href="https://github.com/KlipperScreen/KlipperScreen">KlipperScreen</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/31575189?v=4" alt="jordanruthe avatar" height="64"></th>
|
<th><img src="https://avatars.githubusercontent.com/KlipperScreen?v=4" alt="KlipperScreen Logo" height="64"></th>
|
||||||
<th><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>由 <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
<th>由 <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
||||||
<th>由 <a href="https://github.com/jordanruthe">jordanruthe</a></th>
|
<th>由 <a href="https://github.com/alfrix">alfrix</a></th>
|
||||||
<th>由 <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 align="center">🧩 社区扩展 🧩</h2>
|
||||||
|
|
||||||
|
<table align="center" style="text-align: center;">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/nlef/moonraker-telegram-bot">Moonraker-Telegram-Bot</a></h3></th>
|
<th><h3><a href="https://github.com/nlef/moonraker-telegram-bot">Moonraker-Telegram-Bot</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Kragrathea/pgcode">PrettyGCode for Klipper</a></h3></th>
|
<th><h3><a href="https://github.com/Kragrathea/pgcode">PrettyGCode for Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></th>
|
<th><a href="https://github.com/OctoPrint/OctoPrint"><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></a></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></th>
|
<th><a href="https://github.com/nlef/moonraker-telegram-bot"><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></a></th>
|
||||||
<th><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></th>
|
<th><a href="https://github.com/Kragrathea/pgcode"><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></a></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>由 <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
||||||
<th>由 <a href="https://github.com/nlef">nlef</a></th>
|
<th>由 <a href="https://github.com/nlef">nlef</a></th>
|
||||||
<th>由 <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
<th>由 <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
||||||
<th>由 <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</a></h3></th>
|
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</a></h3></th>
|
||||||
<th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
|
<th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><a href="https://github.com/TheSpaghettiDetective/moonraker-obico"><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></a></th>
|
||||||
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="Mobileraker Logo" height="64"></a></th>
|
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="Mobileraker Logo" height="64"></a></th>
|
||||||
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
|
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
|
||||||
<th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>由 <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
||||||
<th>由 <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
<th>由 <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
||||||
<th>由 <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
<th>由 <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
||||||
<th>由 <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
||||||
<th><h3><a href="https://github.com/staubgeborener/klipper-backup">Klipper-Backup</a></h3></th>
|
<th><h3><a href="https://github.com/staubgeborener/klipper-backup">Klipper-Backup</a></h3></th>
|
||||||
<th><h3><a href="https://simplyprint.io/">SimplyPrint for Klipper</a></h3></th>
|
<th><h3><a href="https://simplyprint.io/">SimplyPrint for Klipper</a></h3></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
||||||
<th><a href="https://github.com/staubgeborener/klipper-backup"><img src="https://avatars.githubusercontent.com/u/28908603?v=4" alt="Staubgeroner Avatar" height="64"></a></th>
|
<th><a href="https://github.com/staubgeborener/klipper-backup"><img src="https://avatars.githubusercontent.com/u/28908603?v=4" alt="Staubgeroner Avatar" height="64"></a></th>
|
||||||
<th><a href="https://github.com/SimplyPrint"><img src="https://avatars.githubusercontent.com/u/64896552?s=200&v=4" alt="" height="64"></a></th>
|
<th><a href="https://github.com/SimplyPrint"><img src="https://avatars.githubusercontent.com/u/64896552?s=200&v=4" alt="SimplyPrint Logo" height="64"></a></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>由 <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
||||||
<th>由 <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
<th>由 <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
||||||
<th>由 <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
<th>由 <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><h3><a href="https://github.com/CodeMasterCody3D/DroidKlipp">DroidKlipp</a></h3></th>
|
||||||
|
<th><h3><a href="https://github.com/PEEKYPAUL/Moongate">Moongate</a></h3></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><a href="https://github.com/CodeMasterCody3D/DroidKlipp"><img src="https://raw.githubusercontent.com/CodeMasterCody3D/DroidKlipp/main/logo.png" alt="DroidKlipp Logo" height="64"></a></th>
|
||||||
|
<th><a href="https://github.com/PEEKYPAUL/Moongate"><img src="https://raw.githubusercontent.com/PEEKYPAUL/Moongate/master/docs/moongate-icon.png" alt="Moongate Logo" height="64"></a></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>由 <a href="https://github.com/CodeMasterCody3D">CodeMasterCody3D</a></th>
|
||||||
|
<th>由 <a href="https://github.com/PEEKYPAUL">PEEKYPAUL</a></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 🎖️ 贡献者
|
<hr>
|
||||||
|
|
||||||
|
<h2 align="center">🎖️ 贡献者 🎖️</h2>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/dw-0/kiauh/graphs/contributors">
|
<a href="https://github.com/dw-0/kiauh/graphs/contributors">
|
||||||
@@ -192,15 +215,10 @@ cd ~ && git clone https://github.com/dw-0/kiauh.git
|
|||||||
<img src="https://repobeats.axiom.co/api/embed/a1afbda9190c04a90cf4bd3061e5573bc836cb05.svg" alt="Repobeats analytics image"/>
|
<img src="https://repobeats.axiom.co/api/embed/a1afbda9190c04a90cf4bd3061e5573bc836cb05.svg" alt="Repobeats analytics image"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ✨ 特别感谢
|
<hr>
|
||||||
|
|
||||||
|
<h2 align="center">✨ 特别感谢 ✨</h2>
|
||||||
|
|
||||||
* 非常感谢 [lixxbox](https://github.com/lixxbox) 设计了如此出色的 KIAUH 标志!
|
* 非常感谢 [lixxbox](https://github.com/lixxbox) 设计了如此出色的 KIAUH 标志!
|
||||||
* 同时,非常感谢所有通过 [Ko-fi](https://ko-fi.com/dw__0) 支持我的工作的人!
|
* 同时,非常感谢所有通过 [Ko-fi](https://ko-fi.com/dw__0) 支持我的工作的人!
|
||||||
* 最后但同样重要的是:感谢所有为 Klipper 社区做出贡献的成员,以及喜欢和分享这个项目的朋友们!
|
* 最后但同样重要的是:感谢所有为 Klipper 社区做出贡献的成员,以及喜欢和分享这个项目的朋友们!
|
||||||
|
|
||||||
<h4 align="center">特别感谢 JetBrains 为本项目提供其出色的软件赞助!</h4>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.jetbrains.com/community/opensource/#support" target="_blank">
|
|
||||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo." height="128">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -135,10 +135,12 @@ function main() {
|
|||||||
export PYTHONPATH="${entrypoint}"
|
export PYTHONPATH="${entrypoint}"
|
||||||
|
|
||||||
clear -x
|
clear -x
|
||||||
python3 "${entrypoint}/kiauh/main.py"
|
python3 "${entrypoint}/kiauh/main.py" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_if_ratos
|
# skip update prompt when arguments are passed -> dont block cli runs
|
||||||
check_euid
|
if [[ $# -eq 0 ]]; then
|
||||||
kiauh_update_dialog
|
kiauh_update_dialog
|
||||||
main
|
fi
|
||||||
|
|
||||||
|
main "$@"
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ CROWSNEST_SERVICE_NAME = "crowsnest.service"
|
|||||||
|
|
||||||
# directories
|
# directories
|
||||||
CROWSNEST_DIR = Path.home().joinpath("crowsnest")
|
CROWSNEST_DIR = Path.home().joinpath("crowsnest")
|
||||||
|
CROWSNEST_ENV_DIR = Path.home().joinpath("crowsnest-env")
|
||||||
|
|
||||||
# files
|
# files
|
||||||
CROWSNEST_MULTI_CONFIG = CROWSNEST_DIR.joinpath("tools/.config")
|
CROWSNEST_MULTI_CONFIG = CROWSNEST_DIR.joinpath("tools/.config")
|
||||||
CROWSNEST_INSTALL_SCRIPT = CROWSNEST_DIR.joinpath("tools/install.sh")
|
CROWSNEST_INSTALL_SCRIPT = CROWSNEST_DIR.joinpath("tools/install.sh")
|
||||||
|
CROWSNEST_DEPS_JSON_FILE = CROWSNEST_DIR.joinpath("system-dependencies.json")
|
||||||
CROWSNEST_BIN_FILE = Path("/usr/local/bin/crowsnest")
|
CROWSNEST_BIN_FILE = Path("/usr/local/bin/crowsnest")
|
||||||
CROWSNEST_LOGROTATE_FILE = Path("/etc/logrotate.d/crowsnest")
|
CROWSNEST_LOGROTATE_FILE = Path("/etc/logrotate.d/crowsnest")
|
||||||
CROWSNEST_SERVICE_FILE = SYSTEMD.joinpath(CROWSNEST_SERVICE_NAME)
|
CROWSNEST_SERVICE_FILE = SYSTEMD.joinpath(CROWSNEST_SERVICE_NAME)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from typing import List
|
|||||||
|
|
||||||
from components.crowsnest import (
|
from components.crowsnest import (
|
||||||
CROWSNEST_BIN_FILE,
|
CROWSNEST_BIN_FILE,
|
||||||
|
CROWSNEST_DEPS_JSON_FILE,
|
||||||
CROWSNEST_DIR,
|
CROWSNEST_DIR,
|
||||||
|
CROWSNEST_ENV_DIR,
|
||||||
CROWSNEST_INSTALL_SCRIPT,
|
CROWSNEST_INSTALL_SCRIPT,
|
||||||
CROWSNEST_LOGROTATE_FILE,
|
CROWSNEST_LOGROTATE_FILE,
|
||||||
CROWSNEST_MULTI_CONFIG,
|
CROWSNEST_MULTI_CONFIG,
|
||||||
@@ -25,6 +27,8 @@ from components.crowsnest import (
|
|||||||
CROWSNEST_SERVICE_NAME,
|
CROWSNEST_SERVICE_NAME,
|
||||||
)
|
)
|
||||||
from components.klipper.klipper import Klipper
|
from components.klipper.klipper import Klipper
|
||||||
|
from components.moonraker.utils.sysdeps_parser import SysDepsParser
|
||||||
|
from components.moonraker.utils.utils import load_sysdeps_json
|
||||||
from core.logger import DialogType, Logger
|
from core.logger import DialogType, Logger
|
||||||
from core.services.backup_service import BackupService
|
from core.services.backup_service import BackupService
|
||||||
from core.settings.kiauh_settings import KiauhSettings
|
from core.settings.kiauh_settings import KiauhSettings
|
||||||
@@ -34,6 +38,7 @@ from utils.common import (
|
|||||||
get_install_status,
|
get_install_status,
|
||||||
)
|
)
|
||||||
from utils.git_utils import (
|
from utils.git_utils import (
|
||||||
|
get_current_branch,
|
||||||
git_clone_wrapper,
|
git_clone_wrapper,
|
||||||
git_pull_wrapper,
|
git_pull_wrapper,
|
||||||
)
|
)
|
||||||
@@ -135,8 +140,7 @@ def update_crowsnest() -> None:
|
|||||||
|
|
||||||
git_pull_wrapper(CROWSNEST_DIR)
|
git_pull_wrapper(CROWSNEST_DIR)
|
||||||
|
|
||||||
deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT)
|
install_crowsnest_packages()
|
||||||
check_install_dependencies({*deps})
|
|
||||||
|
|
||||||
cmd_sysctl_service(CROWSNEST_SERVICE_NAME, "restart")
|
cmd_sysctl_service(CROWSNEST_SERVICE_NAME, "restart")
|
||||||
|
|
||||||
@@ -147,12 +151,68 @@ def update_crowsnest() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_crowsnest_status() -> ComponentStatus:
|
def get_crowsnest_status() -> ComponentStatus:
|
||||||
files = [
|
"""
|
||||||
CROWSNEST_BIN_FILE,
|
Get the current install status of Crowsnest. Depending on the version the installed
|
||||||
CROWSNEST_LOGROTATE_FILE,
|
files are different. If a version is not yet specified, it will search for a
|
||||||
CROWSNEST_SERVICE_FILE,
|
non_existant file resulting in 'Incomplete' status.
|
||||||
]
|
:return: Installation status
|
||||||
return get_install_status(CROWSNEST_DIR, files=files)
|
"""
|
||||||
|
files_dict = {
|
||||||
|
4: [
|
||||||
|
CROWSNEST_BIN_FILE,
|
||||||
|
CROWSNEST_LOGROTATE_FILE,
|
||||||
|
CROWSNEST_SERVICE_FILE,
|
||||||
|
],
|
||||||
|
5: [CROWSNEST_SERVICE_FILE],
|
||||||
|
}
|
||||||
|
version = get_crowsnest_version()
|
||||||
|
|
||||||
|
non_existant = CROWSNEST_DIR.joinpath("non_existant")
|
||||||
|
files = files_dict.get(version, [non_existant])
|
||||||
|
|
||||||
|
env_dir = None
|
||||||
|
if version >= 5:
|
||||||
|
env_dir = CROWSNEST_ENV_DIR
|
||||||
|
return get_install_status(CROWSNEST_DIR, files=files, env_dir=env_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_crowsnest_version() -> int:
|
||||||
|
"""
|
||||||
|
Get the current major version. Starting with v5 the default branch will be named
|
||||||
|
after the major version.
|
||||||
|
:return: Current major version
|
||||||
|
"""
|
||||||
|
version = get_current_branch(CROWSNEST_DIR)
|
||||||
|
if version is None:
|
||||||
|
return 0
|
||||||
|
if version == "master":
|
||||||
|
return 4
|
||||||
|
return int(version.removeprefix("v"))
|
||||||
|
|
||||||
|
|
||||||
|
def install_crowsnest_packages() -> None:
|
||||||
|
Logger.print_status("Parsing Crowsnest system dependencies ...")
|
||||||
|
|
||||||
|
crowsnest_deps = []
|
||||||
|
crowsnest_version = get_crowsnest_version()
|
||||||
|
if crowsnest_version >= 5 and CROWSNEST_DEPS_JSON_FILE.exists():
|
||||||
|
Logger.print_info(
|
||||||
|
f"Parsing system dependencies from {CROWSNEST_DEPS_JSON_FILE.name} ..."
|
||||||
|
)
|
||||||
|
parser = SysDepsParser()
|
||||||
|
sysdeps = load_sysdeps_json(CROWSNEST_DEPS_JSON_FILE)
|
||||||
|
crowsnest_deps.extend(parser.parse_dependencies(sysdeps))
|
||||||
|
|
||||||
|
elif crowsnest_version <= 4 and CROWSNEST_INSTALL_SCRIPT.exists():
|
||||||
|
Logger.print_info(
|
||||||
|
f"Parsing system dependencies from {CROWSNEST_INSTALL_SCRIPT.name} ..."
|
||||||
|
)
|
||||||
|
crowsnest_deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT)
|
||||||
|
|
||||||
|
if not crowsnest_deps:
|
||||||
|
raise ValueError("Error parsing crowsnest dependencies!")
|
||||||
|
|
||||||
|
check_install_dependencies({*crowsnest_deps})
|
||||||
|
|
||||||
|
|
||||||
def remove_crowsnest() -> None:
|
def remove_crowsnest() -> None:
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ from components.klipper.klipper_dialogs import (
|
|||||||
print_select_instance_count_dialog,
|
print_select_instance_count_dialog,
|
||||||
)
|
)
|
||||||
from components.webui_client.base_data import BaseWebClient
|
from components.webui_client.base_data import BaseWebClient
|
||||||
from components.webui_client.client_config.client_config_setup import (
|
from components.webui_client.client_utils import create_client_config_symlink
|
||||||
create_client_config_symlink,
|
|
||||||
)
|
|
||||||
from core.constants import CURRENT_USER
|
from core.constants import CURRENT_USER
|
||||||
from core.instance_manager.base_instance import SUFFIX_BLACKLIST
|
from core.instance_manager.base_instance import SUFFIX_BLACKLIST
|
||||||
from core.logger import DialogType, Logger
|
from core.logger import DialogType, Logger
|
||||||
@@ -88,33 +86,45 @@ def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None:
|
|||||||
name_dict[key] = get_string_input(question, exclude=existing_names, regex=pattern)
|
name_dict[key] = get_string_input(question, exclude=existing_names, regex=pattern)
|
||||||
|
|
||||||
|
|
||||||
def check_user_groups() -> None:
|
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.
|
||||||
|
"""
|
||||||
user_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()]
|
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]
|
missing_groups = [g for g in ["tty", "dialout"] if g not in user_groups]
|
||||||
|
|
||||||
if not missing_groups:
|
if not missing_groups:
|
||||||
return
|
return
|
||||||
|
|
||||||
Logger.print_dialog(
|
if interactive:
|
||||||
DialogType.ATTENTION,
|
Logger.print_dialog(
|
||||||
[
|
DialogType.ATTENTION,
|
||||||
"Your current user is not in group:",
|
[
|
||||||
*[f"● {g}" for g in missing_groups],
|
"Your current user is not in group:",
|
||||||
"\n\n",
|
*[f"● {g}" for g in missing_groups],
|
||||||
"It is possible that you won't be able to successfully connect and/or "
|
"\n\n",
|
||||||
"flash the controller board without your user being a member of that "
|
"It is possible that you won't be able to successfully connect and/or "
|
||||||
"group. If you want to add the current user to the group(s) listed above, "
|
"flash the controller board without your user being a member of that "
|
||||||
"answer with 'Y'. Else skip with 'n'.",
|
"group. If you want to add the current user to the group(s) listed above, "
|
||||||
"\n\n",
|
"answer with 'Y'. Else skip with 'n'.",
|
||||||
"INFO:",
|
"\n\n",
|
||||||
"Relog required for group assignments to take effect!",
|
"INFO:",
|
||||||
],
|
"Relog required for group assignments to take effect!",
|
||||||
)
|
],
|
||||||
|
)
|
||||||
|
|
||||||
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
|
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
|
||||||
log = "Skipped adding user to required groups. You might encounter issues."
|
log = "Skipped adding user to required groups. You might encounter issues."
|
||||||
Logger.print_warn(log)
|
Logger.print_warn(log)
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
Logger.print_info(
|
||||||
|
f"Adding user '{CURRENT_USER}' to required groups: "
|
||||||
|
f"{', '.join(missing_groups)}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for group in missing_groups:
|
for group in missing_groups:
|
||||||
@@ -126,8 +136,9 @@ def check_user_groups() -> None:
|
|||||||
Logger.print_error(f"Unable to add user to usergroups: {e}")
|
Logger.print_error(f"Unable to add user to usergroups: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
log = "Remember to relog/restart this machine for the group(s) to be applied!"
|
if interactive:
|
||||||
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:
|
def handle_disruptive_system_packages() -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
@@ -94,132 +95,234 @@ class KlipperSetupService:
|
|||||||
|
|
||||||
self.msgsvc = MessageService()
|
self.msgsvc = MessageService()
|
||||||
|
|
||||||
def __refresh_state(self) -> None:
|
def _refresh_state(self) -> None:
|
||||||
self.kisvc.load_instances()
|
self.kisvc.load_instances()
|
||||||
self.klipper_list = self.kisvc.get_all_instances()
|
self.klipper_list = self.kisvc.get_all_instances()
|
||||||
|
|
||||||
self.misvc.load_instances()
|
self.misvc.load_instances()
|
||||||
self.moonraker_list = self.misvc.get_all_instances()
|
self.moonraker_list = self.misvc.get_all_instances()
|
||||||
|
|
||||||
def install(self) -> None:
|
def install(
|
||||||
self.__refresh_state()
|
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()
|
||||||
|
|
||||||
Logger.print_status("Installing Klipper ...")
|
Logger.print_status("Installing Klipper ...")
|
||||||
|
|
||||||
match_moonraker: bool = False
|
name_dict: Dict[int, str] = {}
|
||||||
|
|
||||||
# if there are more moonraker instances than klipper instances, ask the user to
|
if custom_names is not None:
|
||||||
# match the klipper instance count to the count of moonraker instances with the same suffix
|
name_dict = custom_names
|
||||||
if len(self.moonraker_list) > len(self.klipper_list):
|
elif match_moonraker and len(self.moonraker_list) > len(self.klipper_list):
|
||||||
is_confirmed = self.__display_moonraker_info()
|
if interactive:
|
||||||
if not is_confirmed:
|
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:
|
||||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||||
return
|
return True
|
||||||
match_moonraker = True
|
|
||||||
|
|
||||||
install_count, name_dict = self.__get_install_count_and_name_dict()
|
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
|
||||||
|
|
||||||
if install_count == 0:
|
self.__handle_instance_names(install_count, name_dict, use_custom_names)
|
||||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
else:
|
||||||
return
|
|
||||||
|
|
||||||
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: ""}
|
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)
|
if not name_dict:
|
||||||
|
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if create_example_cfg is None:
|
||||||
|
create_example_cfg = (
|
||||||
|
get_confirm("Create example printer.cfg?") if interactive else False
|
||||||
|
)
|
||||||
|
|
||||||
create_example_cfg = get_confirm("Create example printer.cfg?")
|
|
||||||
# run the actual installation
|
|
||||||
try:
|
try:
|
||||||
self.__run_setup(name_dict, create_example_cfg)
|
self.__run_setup(name_dict, create_example_cfg, interactive=interactive)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
Logger.print_error(e)
|
Logger.print_error(traceback.format_exc())
|
||||||
Logger.print_error("Klipper installation failed!")
|
Logger.print_error("Klipper installation failed!")
|
||||||
return
|
return False
|
||||||
|
|
||||||
def update(self) -> None:
|
return True
|
||||||
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 not get_confirm("Update Klipper now?"):
|
def update(self, interactive: bool = True) -> bool:
|
||||||
return
|
"""Update Klipper.
|
||||||
|
|
||||||
self.__refresh_state()
|
When called from the TUI, a warning and confirmation are shown. The CLI
|
||||||
|
passes ``interactive=False`` to run silently.
|
||||||
|
|
||||||
if self.settings.kiauh.backup_before_update:
|
Returns ``True`` on success and ``False`` if the update could not be completed.
|
||||||
backup_klipper_dir()
|
"""
|
||||||
|
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.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
InstanceManager.stop_all(self.klipper_list)
|
if not get_confirm("Update Klipper now?"):
|
||||||
git_pull_wrapper(KLIPPER_DIR)
|
return False
|
||||||
install_klipper_packages()
|
|
||||||
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
|
self._refresh_state()
|
||||||
InstanceManager.start_all(self.klipper_list)
|
|
||||||
|
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
|
||||||
|
|
||||||
def remove(
|
def remove(
|
||||||
self,
|
self,
|
||||||
remove_service: bool,
|
remove_service: bool,
|
||||||
remove_dir: bool,
|
remove_dir: bool,
|
||||||
remove_env: bool,
|
remove_env: bool,
|
||||||
) -> None:
|
*,
|
||||||
self.__refresh_state()
|
remove_all: bool = False,
|
||||||
|
instance_suffixes: List[str] | None = None,
|
||||||
|
interactive: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove Klipper.
|
||||||
|
|
||||||
completion_msg = Message(
|
When called from the TUI, the user selects instances interactively. In
|
||||||
title="Klipper Removal Process completed",
|
headless mode (``interactive=False``) the caller MUST express explicit
|
||||||
color=Color.GREEN,
|
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.
|
||||||
|
|
||||||
if remove_service:
|
Returns ``True`` on success and ``False`` if removal could not be completed.
|
||||||
Logger.print_status("Removing Klipper instances ...")
|
"""
|
||||||
if self.klipper_list:
|
self._refresh_state()
|
||||||
instances_to_remove = self.__get_instances_to_remove()
|
|
||||||
self.__remove_instances(instances_to_remove)
|
try:
|
||||||
if instances_to_remove:
|
if interactive:
|
||||||
instance_names = [
|
completion_msg = Message(
|
||||||
i.service_file_path.stem for i in instances_to_remove
|
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.",
|
||||||
]
|
]
|
||||||
txt = f"● Klipper instances removed: {', '.join(instance_names)}"
|
else:
|
||||||
completion_msg.text.append(txt)
|
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)
|
||||||
else:
|
else:
|
||||||
Logger.print_info("No Klipper Services installed! Skipped ...")
|
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)
|
||||||
|
|
||||||
if (remove_dir or remove_env) and unit_file_exists("klipper", suffix="service"):
|
if (remove_dir or remove_env) and unit_file_exists(
|
||||||
completion_msg.text = [
|
"klipper", suffix="service"
|
||||||
"Some Klipper services are still installed:",
|
):
|
||||||
f"● '{KLIPPER_DIR}' was not removed, even though selected for removal.",
|
Logger.print_info(
|
||||||
f"● '{KLIPPER_ENV_DIR}' was not removed, even though selected for removal.",
|
"Klipper services still installed; skipping repository/env removal."
|
||||||
]
|
)
|
||||||
else:
|
return True
|
||||||
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:
|
if remove_dir:
|
||||||
completion_msg.text.insert(0, "The following actions were performed:")
|
run_remove_routines(KLIPPER_DIR)
|
||||||
else:
|
if remove_env:
|
||||||
completion_msg.color = Color.YELLOW
|
run_remove_routines(KLIPPER_ENV_DIR)
|
||||||
completion_msg.centered = True
|
except Exception:
|
||||||
completion_msg.text = ["Nothing to remove."]
|
Logger.print_error(traceback.format_exc())
|
||||||
|
Logger.print_error("Error while removing Klipper!")
|
||||||
|
return False
|
||||||
|
|
||||||
self.msgsvc.set_message(completion_msg)
|
return True
|
||||||
|
|
||||||
def __get_install_count_and_name_dict(self) -> Tuple[int, Dict[int, str]]:
|
def __get_install_count_and_name_dict(self) -> Tuple[int, Dict[int, str]]:
|
||||||
install_count: int | None
|
install_count: int | None
|
||||||
@@ -240,9 +343,16 @@ class KlipperSetupService:
|
|||||||
|
|
||||||
return install_count, name_dict
|
return install_count, name_dict
|
||||||
|
|
||||||
def __run_setup(self, name_dict: Dict[int, str], create_example_cfg: bool) -> None:
|
def __run_setup(
|
||||||
|
self,
|
||||||
|
name_dict: Dict[int, str],
|
||||||
|
create_example_cfg: bool,
|
||||||
|
interactive: bool = True,
|
||||||
|
) -> None:
|
||||||
if not self.klipper_list:
|
if not self.klipper_list:
|
||||||
self.__install_deps()
|
# Only create a fresh venv when none exists; existing venvs are
|
||||||
|
# preserved in both TUI and CLI modes.
|
||||||
|
self.__install_deps(interactive=interactive)
|
||||||
|
|
||||||
for i in name_dict:
|
for i in name_dict:
|
||||||
# skip this iteration if there is already an instance with the name
|
# skip this iteration if there is already an instance with the name
|
||||||
@@ -266,9 +376,9 @@ class KlipperSetupService:
|
|||||||
handle_disruptive_system_packages()
|
handle_disruptive_system_packages()
|
||||||
|
|
||||||
# step 5: check for required group membership
|
# step 5: check for required group membership
|
||||||
check_user_groups()
|
check_user_groups(interactive=interactive)
|
||||||
|
|
||||||
def __install_deps(self) -> None:
|
def __install_deps(self, interactive: bool = True) -> None:
|
||||||
default_repo = (KLIPPER_REPO_URL, "master")
|
default_repo = (KLIPPER_REPO_URL, "master")
|
||||||
repo = self.settings.klipper.repositories
|
repo = self.settings.klipper.repositories
|
||||||
# pull the first repo defined in kiauh.cfg or fallback to the official Klipper repo
|
# pull the first repo defined in kiauh.cfg or fallback to the official Klipper repo
|
||||||
@@ -277,13 +387,19 @@ class KlipperSetupService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
install_klipper_packages()
|
install_klipper_packages()
|
||||||
if create_python_venv(KLIPPER_ENV_DIR, False, False, self.settings.klipper.use_python_binary):
|
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,
|
||||||
|
):
|
||||||
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
|
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
|
||||||
except Exception:
|
except Exception:
|
||||||
Logger.print_error("Error during installation of Klipper requirements!")
|
Logger.print_error("Error during installation of Klipper requirements!")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __display_moonraker_info(self) -> bool:
|
def _display_moonraker_info(self) -> bool:
|
||||||
# todo: only show the klipper instances that are not already installed
|
# todo: only show the klipper instances that are not already installed
|
||||||
Logger.print_dialog(
|
Logger.print_dialog(
|
||||||
DialogType.INFO,
|
DialogType.INFO,
|
||||||
@@ -308,6 +424,17 @@ class KlipperSetupService:
|
|||||||
else:
|
else:
|
||||||
name_dict[key] = str(len(name_dict) + 1)
|
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:
|
def __use_custom_names_or_go_back(self) -> bool | None:
|
||||||
print_select_custom_name_dialog()
|
print_select_custom_name_dialog()
|
||||||
_input: bool | None = get_confirm(
|
_input: bool | None = get_confirm(
|
||||||
@@ -317,7 +444,7 @@ class KlipperSetupService:
|
|||||||
)
|
)
|
||||||
return _input
|
return _input
|
||||||
|
|
||||||
def __get_instances_to_remove(self) -> List[Klipper] | None:
|
def _get_instances_to_remove(self) -> List[Klipper] | None:
|
||||||
start_index = 1
|
start_index = 1
|
||||||
curr_instances: List[Klipper] = self.klipper_list
|
curr_instances: List[Klipper] = self.klipper_list
|
||||||
instance_count = len(curr_instances)
|
instance_count = len(curr_instances)
|
||||||
@@ -341,6 +468,26 @@ class KlipperSetupService:
|
|||||||
|
|
||||||
return [instance_map[selection]]
|
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(
|
def __remove_instances(
|
||||||
self,
|
self,
|
||||||
instance_list: List[Klipper] | None,
|
instance_list: List[Klipper] | None,
|
||||||
@@ -353,11 +500,11 @@ class KlipperSetupService:
|
|||||||
f"Removing instance {instance.service_file_path.stem} ..."
|
f"Removing instance {instance.service_file_path.stem} ..."
|
||||||
)
|
)
|
||||||
InstanceManager.remove(instance)
|
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}'")
|
Logger.print_status(f"Remove '{instance.env_file}'")
|
||||||
if not instance.env_file.exists():
|
if not instance.env_file.exists():
|
||||||
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
|
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
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,8 +8,9 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from subprocess import DEVNULL, PIPE, CalledProcessError, run
|
from subprocess import DEVNULL, PIPE, run
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from components.klipper.klipper import Klipper
|
from components.klipper.klipper import Klipper
|
||||||
@@ -104,87 +105,128 @@ class MoonrakerSetupService:
|
|||||||
|
|
||||||
self.msgsvc = MessageService()
|
self.msgsvc = MessageService()
|
||||||
|
|
||||||
def __refresh_state(self) -> None:
|
def _refresh_state(self) -> None:
|
||||||
self.kisvc.load_instances()
|
self.kisvc.load_instances()
|
||||||
self.klipper_list = self.kisvc.get_all_instances()
|
self.klipper_list = self.kisvc.get_all_instances()
|
||||||
|
|
||||||
self.misvc.load_instances()
|
self.misvc.load_instances()
|
||||||
self.moonraker_list = self.misvc.get_all_instances()
|
self.moonraker_list = self.misvc.get_all_instances()
|
||||||
|
|
||||||
def install(self) -> None:
|
def install(
|
||||||
self.__refresh_state()
|
self,
|
||||||
|
klipper_suffixes: List[str] | None = None,
|
||||||
|
create_example_cfg: bool | None = None,
|
||||||
|
interactive: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Install Moonraker.
|
||||||
|
|
||||||
if not self.__check_requirements(self.klipper_list):
|
When called from the TUI, the Klipper instance is selected interactively.
|
||||||
return
|
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
|
||||||
|
|
||||||
new_instances: List[Moonraker] = []
|
new_instances: List[Moonraker] = []
|
||||||
selected_option: str | Klipper
|
|
||||||
|
|
||||||
if len(self.klipper_list) == 1:
|
if klipper_suffixes is not None:
|
||||||
suffix: str = self.klipper_list[0].suffix
|
for suffix in klipper_suffixes:
|
||||||
new_inst = self.misvc.create_new_instance(suffix)
|
new_instances.append(self.misvc.create_new_instance(suffix))
|
||||||
new_instances.append(new_inst)
|
elif interactive:
|
||||||
|
selected_option: str | Klipper
|
||||||
|
|
||||||
else:
|
if len(self.klipper_list) == 1:
|
||||||
print_moonraker_overview(
|
selected_suffix: str = self.klipper_list[0].suffix
|
||||||
self.klipper_list,
|
new_instances.append(self.misvc.create_new_instance(selected_suffix))
|
||||||
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:
|
else:
|
||||||
klipper_instance: Klipper | None = options.get(selected_option)
|
print_moonraker_overview(
|
||||||
if klipper_instance is None:
|
self.klipper_list,
|
||||||
raise Exception("Error selecting instance!")
|
self.moonraker_list,
|
||||||
new_inst = self.misvc.create_new_instance(klipper_instance.suffix)
|
show_index=True,
|
||||||
new_instances.append(new_inst)
|
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)
|
||||||
|
|
||||||
create_example_cfg = get_confirm("Create example moonraker.conf?")
|
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
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.__run_setup(new_instances, create_example_cfg)
|
self._run_setup(new_instances, create_example_cfg, interactive=interactive)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
Logger.print_error(f"Error while installing Moonraker: {e}")
|
Logger.print_error(traceback.format_exc())
|
||||||
return
|
Logger.print_error("Error while installing Moonraker!")
|
||||||
|
return False
|
||||||
|
|
||||||
def update(self) -> None:
|
return True
|
||||||
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 not get_confirm("Update Moonraker now?"):
|
def update(self, interactive: bool = True) -> bool:
|
||||||
return
|
"""Update Moonraker.
|
||||||
|
|
||||||
self.__refresh_state()
|
When called from the TUI, a warning and confirmation are shown. The CLI
|
||||||
|
passes ``interactive=False`` to run silently.
|
||||||
|
|
||||||
if self.settings.kiauh.backup_before_update:
|
Returns ``True`` on success and ``False`` if the update could not be completed.
|
||||||
backup_moonraker_dir()
|
"""
|
||||||
|
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.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
InstanceManager.stop_all(self.moonraker_list)
|
if not get_confirm("Update Moonraker now?"):
|
||||||
git_pull_wrapper(MOONRAKER_DIR)
|
return False
|
||||||
install_moonraker_packages()
|
|
||||||
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
|
self._refresh_state()
|
||||||
InstanceManager.start_all(self.moonraker_list)
|
|
||||||
|
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
|
||||||
|
|
||||||
def remove(
|
def remove(
|
||||||
self,
|
self,
|
||||||
@@ -192,65 +234,132 @@ class MoonrakerSetupService:
|
|||||||
remove_dir: bool,
|
remove_dir: bool,
|
||||||
remove_env: bool,
|
remove_env: bool,
|
||||||
remove_polkit: bool,
|
remove_polkit: bool,
|
||||||
) -> None:
|
*,
|
||||||
self.__refresh_state()
|
remove_all: bool = False,
|
||||||
|
instance_suffixes: List[str] | None = None,
|
||||||
|
interactive: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove Moonraker.
|
||||||
|
|
||||||
completion_msg = Message(
|
When called from the TUI, the user selects instances interactively. In
|
||||||
title="Moonraker Removal Process completed",
|
headless mode (``interactive=False``) the caller MUST express explicit
|
||||||
color=Color.GREEN,
|
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.
|
||||||
|
|
||||||
if remove_service:
|
Returns ``True`` on success and ``False`` if removal could not be completed.
|
||||||
Logger.print_status("Removing Moonraker instances ...")
|
"""
|
||||||
if self.moonraker_list:
|
self._refresh_state()
|
||||||
instances_to_remove = self.__get_instances_to_remove()
|
|
||||||
self.__remove_instances(instances_to_remove)
|
try:
|
||||||
if instances_to_remove:
|
if interactive:
|
||||||
instance_names = [
|
completion_msg = Message(
|
||||||
i.service_file_path.stem for i in instances_to_remove
|
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.",
|
||||||
]
|
]
|
||||||
txt = f"● Moonraker instances removed: {', '.join(instance_names)}"
|
else:
|
||||||
completion_msg.text.append(txt)
|
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)
|
||||||
else:
|
else:
|
||||||
Logger.print_info("No Moonraker Services installed! Skipped ...")
|
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)
|
||||||
|
|
||||||
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
|
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
|
||||||
"moonraker", suffix="service"
|
"moonraker", suffix="service"
|
||||||
):
|
):
|
||||||
completion_msg.text = [
|
Logger.print_info(
|
||||||
"Some Klipper services are still installed:",
|
"Moonraker services still installed; skipping repository/env removal."
|
||||||
"● Moonraker PolicyKit rules were not removed, even though selected for removal.",
|
)
|
||||||
f"● '{MOONRAKER_DIR}' was not removed, even though selected for removal.",
|
return True
|
||||||
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 completion_msg.text:
|
if remove_polkit:
|
||||||
completion_msg.text.insert(0, "The following actions were performed:")
|
remove_polkit_rules()
|
||||||
else:
|
if remove_dir:
|
||||||
completion_msg.color = Color.YELLOW
|
run_remove_routines(MOONRAKER_DIR)
|
||||||
completion_msg.centered = True
|
if remove_env:
|
||||||
completion_msg.text = ["Nothing to remove."]
|
run_remove_routines(MOONRAKER_ENV_DIR)
|
||||||
|
except Exception:
|
||||||
|
Logger.print_error(traceback.format_exc())
|
||||||
|
Logger.print_error("Error while removing Moonraker!")
|
||||||
|
return False
|
||||||
|
|
||||||
self.msgsvc.set_message(completion_msg)
|
return True
|
||||||
|
|
||||||
def __run_setup(
|
def _run_setup(
|
||||||
self, new_instances: List[Moonraker], create_example_cfg: bool
|
self,
|
||||||
|
new_instances: List[Moonraker],
|
||||||
|
create_example_cfg: bool,
|
||||||
|
interactive: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
check_install_dependencies()
|
check_install_dependencies()
|
||||||
self.__install_deps()
|
# Only create a fresh venv when none exists; existing venvs are
|
||||||
|
# preserved in both TUI and CLI modes.
|
||||||
|
self._install_deps(interactive=interactive)
|
||||||
|
|
||||||
ports_map = self.misvc.get_instance_port_map()
|
ports_map = self.misvc.get_instance_port_map()
|
||||||
for i in new_instances:
|
for i in new_instances:
|
||||||
@@ -289,14 +398,21 @@ class MoonrakerSetupService:
|
|||||||
dialog_content.append("You can access Moonraker via the following URL:")
|
dialog_content.append("You can access Moonraker via the following URL:")
|
||||||
dialog_content.extend(url_list)
|
dialog_content.extend(url_list)
|
||||||
|
|
||||||
Logger.print_dialog(
|
if interactive:
|
||||||
DialogType.CUSTOM,
|
Logger.print_dialog(
|
||||||
custom_title="Moonraker successfully installed!",
|
DialogType.CUSTOM,
|
||||||
custom_color=Color.GREEN,
|
custom_title="Moonraker successfully installed!",
|
||||||
content=dialog_content,
|
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!")
|
||||||
|
|
||||||
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
|
is_klipper_installed = len(klipper_list) >= 1
|
||||||
if not is_klipper_installed:
|
if not is_klipper_installed:
|
||||||
Logger.print_warn("Klipper not installed!")
|
Logger.print_warn("Klipper not installed!")
|
||||||
@@ -306,7 +422,7 @@ class MoonrakerSetupService:
|
|||||||
|
|
||||||
return is_klipper_installed and is_python_ok
|
return is_klipper_installed and is_python_ok
|
||||||
|
|
||||||
def __install_deps(self) -> None:
|
def _install_deps(self, interactive: bool = True) -> None:
|
||||||
default_repo = (MOONRAKER_REPO_URL, "master")
|
default_repo = (MOONRAKER_REPO_URL, "master")
|
||||||
repo = self.settings.moonraker.repositories
|
repo = self.settings.moonraker.repositories
|
||||||
# pull the first repo defined in kiauh.cfg or fallback to the official Moonraker repo
|
# pull the first repo defined in kiauh.cfg or fallback to the official Moonraker repo
|
||||||
@@ -315,18 +431,24 @@ class MoonrakerSetupService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
install_moonraker_packages()
|
install_moonraker_packages()
|
||||||
if create_python_venv(MOONRAKER_ENV_DIR, False, False, self.settings.moonraker.use_python_binary):
|
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,
|
||||||
|
):
|
||||||
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
|
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
|
||||||
if self.settings.moonraker.optional_speedups:
|
if self.settings.moonraker.optional_speedups:
|
||||||
install_python_requirements(
|
install_python_requirements(
|
||||||
MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE
|
MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE
|
||||||
)
|
)
|
||||||
self.__install_polkit()
|
self._install_polkit()
|
||||||
except Exception:
|
except Exception:
|
||||||
Logger.print_error("Error during installation of Moonraker requirements!")
|
Logger.print_error("Error during installation of Moonraker requirements!")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __install_polkit(self) -> None:
|
def _install_polkit(self) -> None:
|
||||||
Logger.print_status("Installing Moonraker policykit rules ...")
|
Logger.print_status("Installing Moonraker policykit rules ...")
|
||||||
|
|
||||||
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
|
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
|
||||||
@@ -337,27 +459,23 @@ class MoonrakerSetupService:
|
|||||||
Logger.print_info("Moonraker policykit rules are already installed.")
|
Logger.print_info("Moonraker policykit rules are already installed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
command = [POLKIT_SCRIPT, "--disable-systemctl"]
|
||||||
command = [POLKIT_SCRIPT, "--disable-systemctl"]
|
result = run(
|
||||||
result = run(
|
command,
|
||||||
command,
|
stderr=PIPE,
|
||||||
stderr=PIPE,
|
stdout=DEVNULL,
|
||||||
stdout=DEVNULL,
|
text=True,
|
||||||
text=True,
|
)
|
||||||
)
|
if result.returncode != 0 or result.stderr:
|
||||||
if result.returncode != 0 or result.stderr:
|
Logger.print_error(f"{result.stderr}", False)
|
||||||
Logger.print_error(f"{result.stderr}", False)
|
Logger.print_error("Installing Moonraker policykit rules failed!")
|
||||||
Logger.print_error("Installing Moonraker policykit rules failed!")
|
# Intentional fail-soft: polkit rules are optional on many systems
|
||||||
return
|
# and a failure here must not abort the whole Moonraker installation.
|
||||||
|
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
|
start_index = 1
|
||||||
curr_instances: List[Moonraker] = self.moonraker_list
|
curr_instances: List[Moonraker] = self.moonraker_list
|
||||||
instance_count = len(curr_instances)
|
instance_count = len(curr_instances)
|
||||||
@@ -383,6 +501,26 @@ class MoonrakerSetupService:
|
|||||||
|
|
||||||
return [instance_map[selection]]
|
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(
|
def __remove_instances(
|
||||||
self,
|
self,
|
||||||
instance_list: List[Moonraker] | None,
|
instance_list: List[Moonraker] | None,
|
||||||
@@ -397,7 +535,7 @@ class MoonrakerSetupService:
|
|||||||
InstanceManager.remove(instance)
|
InstanceManager.remove(instance)
|
||||||
self.__delete_env_file(instance)
|
self.__delete_env_file(instance)
|
||||||
|
|
||||||
self.__refresh_state()
|
self._refresh_state()
|
||||||
|
|
||||||
def __delete_env_file(self, instance: Moonraker):
|
def __delete_env_file(self, instance: Moonraker):
|
||||||
Logger.print_status(f"Remove '{instance.env_file}'")
|
Logger.print_status(f"Remove '{instance.env_file}'")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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 == {}
|
||||||
@@ -8,5 +8,16 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
|
|
||||||
from pathlib import Path
|
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
|
MODULE_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Shared registry of supported web clients
|
||||||
|
CLIENTS: Dict[str, Callable[[], BaseWebClient]] = {
|
||||||
|
"mainsail": MainsailData,
|
||||||
|
"fluidd": FluiddData,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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
|
|
||||||
@@ -1,126 +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 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!")
|
|
||||||
@@ -1,125 +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 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)
|
|
||||||
@@ -1,188 +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 #
|
|
||||||
# ======================================================================= #
|
|
||||||
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,6 +20,7 @@ from components.klipper.klipper import Klipper
|
|||||||
from components.webui_client import MODULE_PATH
|
from components.webui_client import MODULE_PATH
|
||||||
from components.webui_client.base_data import (
|
from components.webui_client.base_data import (
|
||||||
BaseWebClient,
|
BaseWebClient,
|
||||||
|
BaseWebClientConfig,
|
||||||
WebClientType,
|
WebClientType,
|
||||||
)
|
)
|
||||||
from components.webui_client.client_dialogs import print_client_port_select_dialog
|
from components.webui_client.client_dialogs import print_client_port_select_dialog
|
||||||
@@ -482,3 +483,18 @@ def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> Non
|
|||||||
|
|
||||||
with open(config, "w") as f:
|
with open(config, "w") as f:
|
||||||
f.writelines(lines)
|
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,12 +12,14 @@ import textwrap
|
|||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from components.webui_client.base_data import BaseWebClient
|
from components.webui_client.base_data import BaseWebClient
|
||||||
from components.webui_client.client_setup import install_client
|
|
||||||
from components.webui_client.client_utils import (
|
from components.webui_client.client_utils import (
|
||||||
get_client_port_selection,
|
get_client_port_selection,
|
||||||
get_nginx_listen_port,
|
get_nginx_listen_port,
|
||||||
set_listen_port,
|
set_listen_port,
|
||||||
)
|
)
|
||||||
|
from components.webui_client.services.web_client_setup_service import (
|
||||||
|
WebClientSetupService,
|
||||||
|
)
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from core.menus import Option
|
from core.menus import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
@@ -65,7 +67,9 @@ class ClientInstallMenu(BaseMenu):
|
|||||||
print(menu, end="")
|
print(menu, end="")
|
||||||
|
|
||||||
def reinstall_client(self, **kwargs) -> None:
|
def reinstall_client(self, **kwargs) -> None:
|
||||||
install_client(self.client, settings=self.settings, reinstall=True)
|
WebClientSetupService(self.client.name).install(
|
||||||
|
reinstall=True, interactive=True
|
||||||
|
)
|
||||||
|
|
||||||
def change_listen_port(self, **kwargs) -> None:
|
def change_listen_port(self, **kwargs) -> None:
|
||||||
curr_port = self._get_current_port()
|
curr_port = self._get_current_port()
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ from __future__ import annotations
|
|||||||
import textwrap
|
import textwrap
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from components.webui_client import client_remove
|
|
||||||
from components.webui_client.base_data import BaseWebClient
|
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 import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
@@ -100,13 +102,12 @@ class ClientRemoveMenu(BaseMenu):
|
|||||||
print(Color.apply("Nothing selected ...", Color.RED))
|
print(Color.apply("Nothing selected ...", Color.RED))
|
||||||
return
|
return
|
||||||
|
|
||||||
completion_msg = client_remove.run_client_removal(
|
WebClientSetupService(self.client.name).remove(
|
||||||
client=self.client,
|
|
||||||
remove_client=self.remove_client,
|
remove_client=self.remove_client,
|
||||||
remove_client_cfg=self.remove_client_cfg,
|
remove_client_cfg=self.remove_client_cfg,
|
||||||
backup_config=self.backup_config_json,
|
backup_config=self.backup_config_json,
|
||||||
|
interactive=True,
|
||||||
)
|
)
|
||||||
self.message_service.set_message(completion_msg)
|
|
||||||
|
|
||||||
self.remove_client = False
|
self.remove_client = False
|
||||||
self.remove_client_cfg = False
|
self.remove_client_cfg = False
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import 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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from typing import Callable, Dict, List, Tuple
|
||||||
|
|
||||||
|
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||||
|
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||||
|
from components.webui_client.services.web_client_config_setup_service import (
|
||||||
|
WebClientConfigSetupService,
|
||||||
|
)
|
||||||
|
from components.webui_client.services.web_client_setup_service import (
|
||||||
|
WebClientSetupService,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A dispatcher receives the parsed argparse namespace and the parser (so it can
|
||||||
|
# raise ``parser.error`` for invalid input) and returns the CLI exit code.
|
||||||
|
Dispatcher = Callable[[argparse.Namespace, argparse.ArgumentParser], int]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_klipper_install(sub: argparse._SubParsersAction) -> None:
|
||||||
|
p = sub.add_parser("klipper", help="Install Klipper")
|
||||||
|
p.add_argument(
|
||||||
|
"--count", type=int, default=None, help="Number of instances to install"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--name", action="append", default=[], help="Custom instance name(s)"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--create-example-cfg", action="store_true", help="Create example printer.cfg"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--match-moonraker",
|
||||||
|
action="store_true",
|
||||||
|
help="Match Klipper instance count to existing Moonraker instances",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_moonraker_install(sub: argparse._SubParsersAction) -> None:
|
||||||
|
p = sub.add_parser("moonraker", help="Install Moonraker")
|
||||||
|
p.add_argument(
|
||||||
|
"--klipper-suffix",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Klipper suffix to set up Moonraker for (can be repeated)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--create-example-cfg",
|
||||||
|
action="store_true",
|
||||||
|
help="Create example moonraker.conf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_web_client_install(sub: argparse._SubParsersAction) -> None:
|
||||||
|
for name in ("mainsail", "fluidd"):
|
||||||
|
p = sub.add_parser(name, help=f"Install {name.capitalize()}")
|
||||||
|
p.add_argument("--port", type=int, default=None, help="Listen port")
|
||||||
|
p.add_argument(
|
||||||
|
"--install-config",
|
||||||
|
action="store_true",
|
||||||
|
help="Install the recommended client config",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--continue-without-moonraker",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow installation even if Moonraker is not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
sub.add_parser("mainsail-config", help="Install the Mainsail client config")
|
||||||
|
sub.add_parser("fluidd-config", help="Install the Fluidd client config")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_klipper_remove(sub: argparse._SubParsersAction) -> None:
|
||||||
|
p = sub.add_parser("klipper", help="Remove Klipper")
|
||||||
|
p.add_argument("--service", action="store_true", help="Remove Klipper services")
|
||||||
|
p.add_argument("--dir", action="store_true", help="Remove Klipper local repository")
|
||||||
|
p.add_argument(
|
||||||
|
"--env", action="store_true", help="Remove Klipper Python environment"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Remove every installed Klipper instance (destructive)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--instance",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Klipper instance suffix to remove (repeatable)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_moonraker_remove(sub: argparse._SubParsersAction) -> None:
|
||||||
|
p = sub.add_parser("moonraker", help="Remove Moonraker")
|
||||||
|
p.add_argument("--service", action="store_true", help="Remove Moonraker services")
|
||||||
|
p.add_argument(
|
||||||
|
"--dir", action="store_true", help="Remove Moonraker local repository"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--env", action="store_true", help="Remove Moonraker Python environment"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--polkit", action="store_true", help="Remove Moonraker policykit rules"
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Remove every installed Moonraker instance (destructive)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--instance",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Moonraker instance suffix to remove (repeatable)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_web_client_remove(sub: argparse._SubParsersAction) -> None:
|
||||||
|
for name in ("mainsail", "fluidd"):
|
||||||
|
p = sub.add_parser(name, help=f"Remove {name.capitalize()}")
|
||||||
|
p.add_argument("--client", action="store_true", help="Remove the web client")
|
||||||
|
p.add_argument("--config", action="store_true", help="Remove the client config")
|
||||||
|
p.add_argument("--no-backup", action="store_true", help="Skip config backup")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_klipper_update(sub: argparse._SubParsersAction) -> None:
|
||||||
|
p = sub.add_parser("klipper", help="Update Klipper")
|
||||||
|
p.add_argument("--backup", action="store_true", help="Backup before updating")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_moonraker_update(sub: argparse._SubParsersAction) -> None:
|
||||||
|
sub.add_parser("moonraker", help="Update Moonraker")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_web_client_update(sub: argparse._SubParsersAction) -> None:
|
||||||
|
for name in ("mainsail", "fluidd"):
|
||||||
|
sub.add_parser(name, help=f"Update {name.capitalize()}")
|
||||||
|
sub.add_parser("mainsail-config", help="Update the Mainsail client config")
|
||||||
|
sub.add_parser("fluidd-config", help="Update the Fluidd client config")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="kiauh")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
install = subparsers.add_parser("install", help="Install a component")
|
||||||
|
install_sub = install.add_subparsers(dest="component", required=True)
|
||||||
|
_add_klipper_install(install_sub)
|
||||||
|
_add_moonraker_install(install_sub)
|
||||||
|
_add_web_client_install(install_sub)
|
||||||
|
|
||||||
|
remove = subparsers.add_parser("remove", help="Remove a component")
|
||||||
|
remove_sub = remove.add_subparsers(dest="component", required=True)
|
||||||
|
_add_klipper_remove(remove_sub)
|
||||||
|
_add_moonraker_remove(remove_sub)
|
||||||
|
_add_web_client_remove(remove_sub)
|
||||||
|
|
||||||
|
update = subparsers.add_parser("update", help="Update a component")
|
||||||
|
update_sub = update.add_subparsers(dest="component", required=True)
|
||||||
|
_add_klipper_update(update_sub)
|
||||||
|
_add_moonraker_update(update_sub)
|
||||||
|
_add_web_client_update(update_sub)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Command handlers: one callable per (command, component) pair. #
|
||||||
|
# Adding a new component is a matter of registering a handler here instead of #
|
||||||
|
# extending the previous long if/elif chain. #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _install_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||||
|
if args.count is not None and args.name and args.count != len(args.name):
|
||||||
|
parser.error("--count must match the number of --name values")
|
||||||
|
|
||||||
|
service = KlipperSetupService()
|
||||||
|
custom_names = {i: name for i, name in enumerate(args.name)} if args.name else None
|
||||||
|
result = service.install(
|
||||||
|
count=args.count,
|
||||||
|
custom_names=custom_names,
|
||||||
|
create_example_cfg=args.create_example_cfg,
|
||||||
|
match_moonraker=args.match_moonraker,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||||
|
if not (args.service or args.dir or args.env):
|
||||||
|
parser.error(
|
||||||
|
"specify at least one of --service, --dir, --env for 'remove klipper'"
|
||||||
|
)
|
||||||
|
if args.service and not (args.all or args.instance):
|
||||||
|
# refuse to silently wipe every Klipper instance.
|
||||||
|
parser.error(
|
||||||
|
"removing Klipper services is destructive; pass --all or "
|
||||||
|
"--instance <suffix> (repeatable) to select what to remove"
|
||||||
|
)
|
||||||
|
service = KlipperSetupService()
|
||||||
|
result = service.remove(
|
||||||
|
remove_service=args.service,
|
||||||
|
remove_dir=args.dir,
|
||||||
|
remove_env=args.env,
|
||||||
|
remove_all=args.all,
|
||||||
|
instance_suffixes=args.instance or None,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _update_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||||
|
service = KlipperSetupService()
|
||||||
|
if args.backup:
|
||||||
|
service.settings.kiauh.backup_before_update = True
|
||||||
|
result = service.update(interactive=False)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _install_moonraker(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
service = MoonrakerSetupService()
|
||||||
|
result = service.install(
|
||||||
|
klipper_suffixes=args.klipper_suffix or None,
|
||||||
|
create_example_cfg=args.create_example_cfg,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||||
|
if not (args.service or args.dir or args.env or args.polkit):
|
||||||
|
parser.error(
|
||||||
|
"specify at least one of --service, --dir, --env, --polkit "
|
||||||
|
"for 'remove moonraker'"
|
||||||
|
)
|
||||||
|
if args.service and not (args.all or args.instance):
|
||||||
|
# refuse to silently wipe every Moonraker instance.
|
||||||
|
parser.error(
|
||||||
|
"removing Moonraker services is destructive; pass --all or "
|
||||||
|
"--instance <suffix> (repeatable) to select what to remove"
|
||||||
|
)
|
||||||
|
service = MoonrakerSetupService()
|
||||||
|
result = service.remove(
|
||||||
|
remove_service=args.service,
|
||||||
|
remove_dir=args.dir,
|
||||||
|
remove_env=args.env,
|
||||||
|
remove_polkit=args.polkit,
|
||||||
|
remove_all=args.all,
|
||||||
|
instance_suffixes=args.instance or None,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _update_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||||
|
service = MoonrakerSetupService()
|
||||||
|
result = service.update(interactive=False)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _install_web_client(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
service = WebClientSetupService(args.component)
|
||||||
|
result = service.install(
|
||||||
|
port=args.port,
|
||||||
|
install_client_cfg=args.install_config,
|
||||||
|
continue_without_moonraker=args.continue_without_moonraker,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _install_web_client_config(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
client_name = args.component.replace("-config", "")
|
||||||
|
result = WebClientConfigSetupService(client_name).install(interactive=False)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_web_client(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
if not (args.client or args.config):
|
||||||
|
parser.error(
|
||||||
|
f"specify at least one of --client, --config for 'remove {args.component}'"
|
||||||
|
)
|
||||||
|
service = WebClientSetupService(args.component)
|
||||||
|
result = service.remove(
|
||||||
|
remove_client=args.client,
|
||||||
|
remove_client_cfg=args.config,
|
||||||
|
backup_config=not args.no_backup,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _update_web_client(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
result = WebClientSetupService(args.component).update()
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _update_web_client_config(
|
||||||
|
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||||
|
) -> int:
|
||||||
|
client_name = args.component.replace("-config", "")
|
||||||
|
result = WebClientConfigSetupService(client_name).update(interactive=False)
|
||||||
|
return 0 if result else 1
|
||||||
|
|
||||||
|
|
||||||
|
# Dispatch registry: (command, component) -> handler. Keeping this as a module
|
||||||
|
# constant (not a closure) keeps ``run_cli`` trivial and lets tests assert which
|
||||||
|
# combinations are actually supported.
|
||||||
|
DISPATCH: Dict[Tuple[str, str], Dispatcher] = {
|
||||||
|
("install", "klipper"): _install_klipper,
|
||||||
|
("remove", "klipper"): _remove_klipper,
|
||||||
|
("update", "klipper"): _update_klipper,
|
||||||
|
("install", "moonraker"): _install_moonraker,
|
||||||
|
("remove", "moonraker"): _remove_moonraker,
|
||||||
|
("update", "moonraker"): _update_moonraker,
|
||||||
|
("install", "mainsail"): _install_web_client,
|
||||||
|
("install", "fluidd"): _install_web_client,
|
||||||
|
("remove", "mainsail"): _remove_web_client,
|
||||||
|
("remove", "fluidd"): _remove_web_client,
|
||||||
|
("update", "mainsail"): _update_web_client,
|
||||||
|
("update", "fluidd"): _update_web_client,
|
||||||
|
("install", "mainsail-config"): _install_web_client_config,
|
||||||
|
("install", "fluidd-config"): _install_web_client_config,
|
||||||
|
("update", "mainsail-config"): _update_web_client_config,
|
||||||
|
("update", "fluidd-config"): _update_web_client_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli(argv: List[str] | None = None) -> int:
|
||||||
|
"""Run a headless CLI command.
|
||||||
|
|
||||||
|
Returns 0 on success, -1 if no command was provided (-> fall back to TUI),
|
||||||
|
and a positive exit code when a command reports failure.
|
||||||
|
"""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
handler = DISPATCH.get((args.command, args.component))
|
||||||
|
if handler is None:
|
||||||
|
parser.error(f"Unsupported command: {args.command} {args.component}")
|
||||||
|
return handler(args, parser)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
sys.exit(run_cli())
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
import core.cli as cli_module
|
||||||
|
import pytest
|
||||||
|
from core.cli import run_cli
|
||||||
|
|
||||||
|
|
||||||
|
class FakeKlipperService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: List[Dict[str, Any]] = []
|
||||||
|
self.results: Dict[str, bool] = {}
|
||||||
|
self.settings = type(
|
||||||
|
"Settings",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"kiauh": type(
|
||||||
|
"KiauhSettingsSection", (), {"backup_before_update": False}
|
||||||
|
)()
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
|
||||||
|
def install(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "install", "kwargs": kwargs})
|
||||||
|
return self.results.get("install", True)
|
||||||
|
|
||||||
|
def remove(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "remove", "kwargs": kwargs})
|
||||||
|
return self.results.get("remove", True)
|
||||||
|
|
||||||
|
def update(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "update", "kwargs": kwargs})
|
||||||
|
return self.results.get("update", True)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMoonrakerService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: List[Dict[str, Any]] = []
|
||||||
|
self.results: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
def install(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "install", "kwargs": kwargs})
|
||||||
|
return self.results.get("install", True)
|
||||||
|
|
||||||
|
def remove(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "remove", "kwargs": kwargs})
|
||||||
|
return self.results.get("remove", True)
|
||||||
|
|
||||||
|
def update(self, **kwargs: Any) -> bool:
|
||||||
|
self.calls.append({"method": "update", "kwargs": kwargs})
|
||||||
|
return self.results.get("update", True)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebClientService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.install_calls: List[Dict[str, Any]] = []
|
||||||
|
self.remove_calls: List[Dict[str, Any]] = []
|
||||||
|
self.update_calls: List[str] = []
|
||||||
|
self.results: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
def install(self, **kwargs: Any) -> bool:
|
||||||
|
self.install_calls.append(kwargs)
|
||||||
|
return self.results.get("install", True)
|
||||||
|
|
||||||
|
def remove(self, **kwargs: Any) -> bool:
|
||||||
|
self.remove_calls.append(kwargs)
|
||||||
|
return self.results.get("remove", True)
|
||||||
|
|
||||||
|
def update(self) -> bool:
|
||||||
|
self.update_calls.append("update")
|
||||||
|
return self.results.get("update", True)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebClientConfigService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.install_calls: List[Dict[str, Any]] = []
|
||||||
|
self.update_calls: List[Dict[str, Any]] = []
|
||||||
|
self.results: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
def install(self, **kwargs: Any) -> bool:
|
||||||
|
self.install_calls.append(kwargs)
|
||||||
|
return self.results.get("install", True)
|
||||||
|
|
||||||
|
def update(self, **kwargs: Any) -> bool:
|
||||||
|
self.update_calls.append(kwargs)
|
||||||
|
return self.results.get("update", True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_service(monkeypatch: pytest.MonkeyPatch) -> FakeKlipperService:
|
||||||
|
fake = FakeKlipperService()
|
||||||
|
monkeypatch.setattr("core.cli.KlipperSetupService", lambda: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_moonraker_service(monkeypatch: pytest.MonkeyPatch) -> FakeMoonrakerService:
|
||||||
|
fake = FakeMoonrakerService()
|
||||||
|
monkeypatch.setattr("core.cli.MoonrakerSetupService", lambda: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_web_client_service(monkeypatch: pytest.MonkeyPatch) -> FakeWebClientService:
|
||||||
|
fake = FakeWebClientService()
|
||||||
|
monkeypatch.setattr("core.cli.WebClientSetupService", lambda name: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_web_client_config_service(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> FakeWebClientConfigService:
|
||||||
|
fake = FakeWebClientConfigService()
|
||||||
|
monkeypatch.setattr("core.cli.WebClientConfigSetupService", lambda name: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliDispatch:
|
||||||
|
def test_no_args_returns_tui_signal(self) -> None:
|
||||||
|
assert run_cli([]) == -1
|
||||||
|
|
||||||
|
def test_install_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||||
|
rc = run_cli(["install", "klipper", "--count", "2"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls == [
|
||||||
|
{
|
||||||
|
"method": "install",
|
||||||
|
"kwargs": {
|
||||||
|
"count": 2,
|
||||||
|
"custom_names": None,
|
||||||
|
"create_example_cfg": False,
|
||||||
|
"match_moonraker": False,
|
||||||
|
"interactive": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_install_klipper_default_count_is_none(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["install", "klipper"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls[0]["kwargs"]["count"] is None
|
||||||
|
|
||||||
|
def test_install_klipper_with_names(self, fake_service: FakeKlipperService) -> None:
|
||||||
|
rc = run_cli(["install", "klipper", "--name", "a", "--name", "b"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls[0]["kwargs"]["custom_names"] == {0: "a", 1: "b"}
|
||||||
|
assert fake_service.calls[0]["kwargs"]["count"] is None
|
||||||
|
|
||||||
|
def test_install_klipper_count_and_name_mismatch_rejected(self) -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli([
|
||||||
|
"install",
|
||||||
|
"klipper",
|
||||||
|
"--count",
|
||||||
|
"3",
|
||||||
|
"--name",
|
||||||
|
"a",
|
||||||
|
"--name",
|
||||||
|
"b",
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_install_klipper_with_flags(self, fake_service: FakeKlipperService) -> None:
|
||||||
|
rc = run_cli([
|
||||||
|
"install",
|
||||||
|
"klipper",
|
||||||
|
"--create-example-cfg",
|
||||||
|
"--match-moonraker",
|
||||||
|
])
|
||||||
|
assert rc == 0
|
||||||
|
kwargs = fake_service.calls[0]["kwargs"]
|
||||||
|
assert kwargs["create_example_cfg"] is True
|
||||||
|
assert kwargs["match_moonraker"] is True
|
||||||
|
assert kwargs["interactive"] is False
|
||||||
|
|
||||||
|
def test_install_klipper_failure_returns_nonzero(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
fake_service.results["install"] = False
|
||||||
|
assert run_cli(["install", "klipper"]) == 1
|
||||||
|
|
||||||
|
def test_remove_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||||
|
rc = run_cli(["remove", "klipper", "--service", "--all", "--dir", "--env"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls == [
|
||||||
|
{
|
||||||
|
"method": "remove",
|
||||||
|
"kwargs": {
|
||||||
|
"remove_service": True,
|
||||||
|
"interactive": False,
|
||||||
|
"remove_dir": True,
|
||||||
|
"remove_env": True,
|
||||||
|
"remove_all": True,
|
||||||
|
"instance_suffixes": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_remove_klipper_failure_returns_nonzero(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
fake_service.results["remove"] = False
|
||||||
|
assert run_cli(["remove", "klipper", "--service", "--all"]) == 1
|
||||||
|
|
||||||
|
def test_remove_klipper_no_flags_is_rejected(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
# a remove with no removal flags must not silently succeed
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli(["remove", "klipper"])
|
||||||
|
assert fake_service.calls == []
|
||||||
|
|
||||||
|
def test_remove_klipper_service_without_explicit_intent_is_rejected(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
# `--service` alone must NOT silently wipe all instances.
|
||||||
|
# The user must pass `--all` (or `--instance <suffix>`).
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli(["remove", "klipper", "--service"])
|
||||||
|
assert fake_service.calls == []
|
||||||
|
|
||||||
|
def test_remove_klipper_with_instance_suffix(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli([
|
||||||
|
"remove",
|
||||||
|
"klipper",
|
||||||
|
"--service",
|
||||||
|
"--instance",
|
||||||
|
"a",
|
||||||
|
"--instance",
|
||||||
|
"b",
|
||||||
|
])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls[0]["kwargs"]["instance_suffixes"] == ["a", "b"]
|
||||||
|
assert fake_service.calls[0]["kwargs"]["remove_all"] is False
|
||||||
|
|
||||||
|
def test_update_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||||
|
rc = run_cli(["update", "klipper"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.calls == [
|
||||||
|
{"method": "update", "kwargs": {"interactive": False}}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_update_klipper_with_backup_flag(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["update", "klipper", "--backup"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_service.settings.kiauh.backup_before_update is True
|
||||||
|
|
||||||
|
def test_update_klipper_failure_returns_nonzero(
|
||||||
|
self, fake_service: FakeKlipperService
|
||||||
|
) -> None:
|
||||||
|
fake_service.results["update"] = False
|
||||||
|
assert run_cli(["update", "klipper"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoonrakerCliDispatch:
|
||||||
|
def test_install_moonraker_default(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["install", "moonraker"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_moonraker_service.calls == [
|
||||||
|
{
|
||||||
|
"method": "install",
|
||||||
|
"kwargs": {
|
||||||
|
"klipper_suffixes": None,
|
||||||
|
"create_example_cfg": False,
|
||||||
|
"interactive": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_install_moonraker_with_suffixes(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli([
|
||||||
|
"install",
|
||||||
|
"moonraker",
|
||||||
|
"--klipper-suffix",
|
||||||
|
"a",
|
||||||
|
"--klipper-suffix",
|
||||||
|
"b",
|
||||||
|
])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_moonraker_service.calls[0]["kwargs"]["klipper_suffixes"] == [
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_install_moonraker_failure_returns_nonzero(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
fake_moonraker_service.results["install"] = False
|
||||||
|
assert run_cli(["install", "moonraker"]) == 1
|
||||||
|
|
||||||
|
def test_remove_moonraker(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli([
|
||||||
|
"remove",
|
||||||
|
"moonraker",
|
||||||
|
"--service",
|
||||||
|
"--all",
|
||||||
|
"--dir",
|
||||||
|
"--env",
|
||||||
|
"--polkit",
|
||||||
|
])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_moonraker_service.calls == [
|
||||||
|
{
|
||||||
|
"method": "remove",
|
||||||
|
"kwargs": {
|
||||||
|
"remove_service": True,
|
||||||
|
"remove_dir": True,
|
||||||
|
"remove_env": True,
|
||||||
|
"remove_polkit": True,
|
||||||
|
"interactive": False,
|
||||||
|
"remove_all": True,
|
||||||
|
"instance_suffixes": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_remove_moonraker_service_without_explicit_intent_is_rejected(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
# `--service` alone must NOT silently wipe all instances.
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli(["remove", "moonraker", "--service"])
|
||||||
|
assert fake_moonraker_service.calls == []
|
||||||
|
|
||||||
|
def test_update_moonraker(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["update", "moonraker"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_moonraker_service.calls == [
|
||||||
|
{"method": "update", "kwargs": {"interactive": False}}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_remove_moonraker_no_flags_is_rejected(
|
||||||
|
self, fake_moonraker_service: FakeMoonrakerService
|
||||||
|
) -> None:
|
||||||
|
# a remove with no removal flags must not silently succeed
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli(["remove", "moonraker"])
|
||||||
|
assert fake_moonraker_service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClientCliDispatch:
|
||||||
|
def test_install_mainsail(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli([
|
||||||
|
"install",
|
||||||
|
"mainsail",
|
||||||
|
"--port",
|
||||||
|
"8080",
|
||||||
|
"--install-config",
|
||||||
|
"--continue-without-moonraker",
|
||||||
|
])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_service.install_calls == [
|
||||||
|
{
|
||||||
|
"port": 8080,
|
||||||
|
"install_client_cfg": True,
|
||||||
|
"continue_without_moonraker": True,
|
||||||
|
"interactive": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_install_fluidd_default(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["install", "fluidd"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_service.install_calls == [
|
||||||
|
{
|
||||||
|
"port": None,
|
||||||
|
"install_client_cfg": False,
|
||||||
|
"continue_without_moonraker": False,
|
||||||
|
"interactive": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_install_client_config_runs_non_interactively(
|
||||||
|
self, fake_web_client_config_service: FakeWebClientConfigService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["install", "mainsail-config"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_config_service.install_calls == [{"interactive": False}]
|
||||||
|
|
||||||
|
def test_install_web_client_failure_returns_nonzero(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
fake_web_client_service.results["install"] = False
|
||||||
|
assert run_cli(["install", "mainsail"]) == 1
|
||||||
|
|
||||||
|
def test_remove_mainsail_no_flags_is_rejected(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
# a remove with no removal flags must not silently succeed
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_cli(["remove", "mainsail"])
|
||||||
|
assert fake_web_client_service.remove_calls == []
|
||||||
|
|
||||||
|
def test_remove_mainsail_with_client_and_config(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["remove", "mainsail", "--client", "--config"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_service.remove_calls == [
|
||||||
|
{
|
||||||
|
"remove_client": True,
|
||||||
|
"remove_client_cfg": True,
|
||||||
|
"backup_config": True,
|
||||||
|
"interactive": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_remove_fluidd_no_backup(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["remove", "fluidd", "--client", "--no-backup"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_service.remove_calls == [
|
||||||
|
{
|
||||||
|
"remove_client": True,
|
||||||
|
"remove_client_cfg": False,
|
||||||
|
"backup_config": False,
|
||||||
|
"interactive": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_update_mainsail(
|
||||||
|
self, fake_web_client_service: FakeWebClientService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["update", "mainsail"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_service.update_calls == ["update"]
|
||||||
|
|
||||||
|
def test_update_fluidd_config_runs_non_interactively(
|
||||||
|
self, fake_web_client_config_service: FakeWebClientConfigService
|
||||||
|
) -> None:
|
||||||
|
rc = run_cli(["update", "fluidd-config"])
|
||||||
|
assert rc == 0
|
||||||
|
assert fake_web_client_config_service.update_calls == [{"interactive": False}]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatchRegistry:
|
||||||
|
"""``run_cli`` must use a dispatch registry instead of a long
|
||||||
|
if/elif chain, and the registry must cover every (command, component) pair
|
||||||
|
the argument parser can produce."""
|
||||||
|
|
||||||
|
_EXPECTED: Set[tuple] = {
|
||||||
|
("install", "klipper"),
|
||||||
|
("remove", "klipper"),
|
||||||
|
("update", "klipper"),
|
||||||
|
("install", "moonraker"),
|
||||||
|
("remove", "moonraker"),
|
||||||
|
("update", "moonraker"),
|
||||||
|
("install", "mainsail"),
|
||||||
|
("install", "fluidd"),
|
||||||
|
("remove", "mainsail"),
|
||||||
|
("remove", "fluidd"),
|
||||||
|
("update", "mainsail"),
|
||||||
|
("update", "fluidd"),
|
||||||
|
("install", "mainsail-config"),
|
||||||
|
("install", "fluidd-config"),
|
||||||
|
("update", "mainsail-config"),
|
||||||
|
("update", "fluidd-config"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_dispatch_registry_exists_and_covers_every_pair(self) -> None:
|
||||||
|
dispatch = getattr(cli_module, "DISPATCH", None)
|
||||||
|
assert dispatch is not None, "run_cli must expose a DISPATCH registry"
|
||||||
|
assert set(dispatch.keys()) == self._EXPECTED
|
||||||
|
for handler in dispatch.values():
|
||||||
|
assert callable(handler)
|
||||||
|
|
||||||
|
def test_subparser_helpers_are_typed_not_any(self) -> None:
|
||||||
|
# the ``_add_*`` helpers must accept ``argparse._SubParsersAction``, not ``Any``.
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
for name in dir(cli_module):
|
||||||
|
if not name.startswith("_add_"):
|
||||||
|
continue
|
||||||
|
func = getattr(cli_module, name)
|
||||||
|
if not inspect.isfunction(func):
|
||||||
|
continue
|
||||||
|
hints = inspect.signature(func).parameters.get("sub")
|
||||||
|
assert hints is not None
|
||||||
|
assert hints.annotation is not Any, f"{name} must not type ``sub`` as Any"
|
||||||
|
assert "SubParsersAction" in str(hints.annotation), (
|
||||||
|
f"{name} must type ``sub`` as an argparse SubParsersAction"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackaging:
|
||||||
|
def test_pyproject_metadata_allows_editable_dev_install(self) -> None:
|
||||||
|
project_root = Path(__file__).resolve().parents[4]
|
||||||
|
import subprocess as sp
|
||||||
|
|
||||||
|
result = sp.run(
|
||||||
|
["python", "-m", "pip", "install", "--dry-run", "-e", ".[dev]"],
|
||||||
|
cwd=project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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
|
||||||
@@ -15,16 +15,17 @@ from components.crowsnest.crowsnest import install_crowsnest
|
|||||||
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||||
from components.klipperscreen.klipperscreen import install_klipperscreen
|
from components.klipperscreen.klipperscreen import install_klipperscreen
|
||||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
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.fluidd_data import FluiddData
|
||||||
from components.webui_client.mainsail_data import MainsailData
|
from components.webui_client.mainsail_data import MainsailData
|
||||||
from components.webui_client.menus.client_install_menu import ClientInstallMenu
|
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 import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.settings.kiauh_settings import KiauhSettings
|
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
|
|
||||||
|
|
||||||
@@ -87,20 +88,20 @@ class InstallMenu(BaseMenu):
|
|||||||
if client.client_dir.exists():
|
if client.client_dir.exists():
|
||||||
ClientInstallMenu(client, self.__class__).run()
|
ClientInstallMenu(client, self.__class__).run()
|
||||||
else:
|
else:
|
||||||
install_client(client, settings=KiauhSettings())
|
WebClientSetupService("mainsail").install()
|
||||||
|
|
||||||
def install_mainsail_config(self, **kwargs) -> None:
|
def install_mainsail_config(self, **kwargs) -> None:
|
||||||
install_client_config(MainsailData())
|
WebClientConfigSetupService("mainsail").install()
|
||||||
|
|
||||||
def install_fluidd(self, **kwargs) -> None:
|
def install_fluidd(self, **kwargs) -> None:
|
||||||
client: FluiddData = FluiddData()
|
client: FluiddData = FluiddData()
|
||||||
if client.client_dir.exists():
|
if client.client_dir.exists():
|
||||||
ClientInstallMenu(client, self.__class__).run()
|
ClientInstallMenu(client, self.__class__).run()
|
||||||
else:
|
else:
|
||||||
install_client(client, settings=KiauhSettings())
|
WebClientSetupService("fluidd").install()
|
||||||
|
|
||||||
def install_fluidd_config(self, **kwargs) -> None:
|
def install_fluidd_config(self, **kwargs) -> None:
|
||||||
install_client_config(FluiddData())
|
WebClientConfigSetupService("fluidd").install()
|
||||||
|
|
||||||
def install_klipperscreen(self, **kwargs) -> None:
|
def install_klipperscreen(self, **kwargs) -> None:
|
||||||
install_klipperscreen()
|
install_klipperscreen()
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List, Literal, Type
|
from typing import List, Literal, Type
|
||||||
|
|
||||||
from core.logger import Logger, DialogType
|
from core.logger import DialogType, Logger
|
||||||
from core.menus import Option
|
from core.menus import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.settings.kiauh_settings import KiauhSettings, Repository
|
from core.settings.kiauh_settings import KiauhSettings, Repository
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
from procedures.switch_repo import run_switch_repo_routine
|
from procedures.switch_repo import run_switch_repo_routine
|
||||||
from utils.input_utils import get_string_input, get_number_input, get_confirm
|
from utils.input_utils import get_confirm, get_number_input, get_string_input
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
# noinspection PyUnusedLocal
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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"]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
@@ -22,16 +22,18 @@ from components.klipperscreen.klipperscreen import (
|
|||||||
)
|
)
|
||||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||||
from components.moonraker.utils.utils import get_moonraker_status
|
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 (
|
from components.webui_client.client_utils import (
|
||||||
get_client_config_status,
|
get_client_config_status,
|
||||||
get_client_status,
|
get_client_status,
|
||||||
)
|
)
|
||||||
from components.webui_client.fluidd_data import FluiddData
|
from components.webui_client.fluidd_data import FluiddData
|
||||||
from components.webui_client.mainsail_data import MainsailData
|
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.logger import DialogType, Logger
|
||||||
from core.menus import Option
|
from core.menus import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
@@ -203,29 +205,25 @@ class UpdateMenu(BaseMenu):
|
|||||||
def update_mainsail(self, **kwargs) -> None:
|
def update_mainsail(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"mainsail",
|
"mainsail",
|
||||||
update_client,
|
WebClientSetupService("mainsail").update,
|
||||||
self.mainsail_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_mainsail_config(self, **kwargs) -> None:
|
def update_mainsail_config(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"mainsail_config",
|
"mainsail_config",
|
||||||
update_client_config,
|
WebClientConfigSetupService("mainsail").update,
|
||||||
self.mainsail_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_fluidd(self, **kwargs) -> None:
|
def update_fluidd(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"fluidd",
|
"fluidd",
|
||||||
update_client,
|
WebClientSetupService("fluidd").update,
|
||||||
self.fluidd_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_fluidd_config(self, **kwargs) -> None:
|
def update_fluidd_config(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"fluidd_config",
|
"fluidd_config",
|
||||||
update_client_config,
|
WebClientConfigSetupService("fluidd").update,
|
||||||
self.fluidd_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_klipperscreen(self, **kwargs) -> None:
|
def update_klipperscreen(self, **kwargs) -> None:
|
||||||
@@ -254,7 +252,16 @@ class UpdateMenu(BaseMenu):
|
|||||||
self._fetch_system_package_update_status()
|
self._fetch_system_package_update_status()
|
||||||
|
|
||||||
def _fetch_system_package_update_status(self) -> None:
|
def _fetch_system_package_update_status(self) -> None:
|
||||||
update_system_package_lists(silent=True)
|
# 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})"
|
||||||
|
)
|
||||||
self.packages = get_upgradable_packages()
|
self.packages = get_upgradable_packages()
|
||||||
self.package_count = len(self.packages)
|
self.package_count = len(self.packages)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
from core.simple_config_parser.tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import (
|
from core.simple_config_parser.simple_config_parser import (
|
||||||
BlankLine,
|
BlankLine,
|
||||||
CommentLine,
|
CommentLine,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.joinpath("assets")
|
BASE_DIR = Path(__file__).parent.parent.joinpath("assets")
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import (
|
from core.simple_config_parser.simple_config_parser import (
|
||||||
MultiLineOption,
|
MultiLineOption,
|
||||||
NoOptionError,
|
NoOptionError,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import (
|
from core.simple_config_parser.simple_config_parser import (
|
||||||
DuplicateSectionError,
|
DuplicateSectionError,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent / "assets"
|
BASE_DIR = Path(__file__).parent.parent / "assets"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
from core.simple_config_parser.simple_config_parser import SimpleConfigParser
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent / "assets"
|
BASE_DIR = Path(__file__).parent.parent / "assets"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2026 Cody Dixon #
|
||||||
|
# #
|
||||||
|
# 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 pathlib import Path
|
||||||
|
|
||||||
|
# repo
|
||||||
|
DROIDKLIPP_REPO = "https://github.com/CodeMasterCody3D/DroidKlipp"
|
||||||
|
DROIDKLIPP_APK_URL = "https://github.com/CodeMasterCody3D/DroidKlipp-Android-APK/releases/latest/download/DroidKlipp.apk"
|
||||||
|
|
||||||
|
# directories
|
||||||
|
DROIDKLIPP_DIR = Path.home().joinpath("DroidKlipp")
|
||||||
|
|
||||||
|
# files
|
||||||
|
DROIDKLIPP_INSTALL_SCRIPT = DROIDKLIPP_DIR.joinpath("install_droidklipp.sh")
|
||||||
|
DROIDKLIPP_UNINSTALL_SCRIPT = DROIDKLIPP_DIR.joinpath("uninstall_droidklipp.sh")
|
||||||
|
DROIDKLIPP_MONITOR_FILE = DROIDKLIPP_DIR.joinpath("droidklipp_monitor.py")
|
||||||
|
DROIDKLIPP_DEPLOYED_MONITOR = Path.home().joinpath("droidklipp_monitor.py")
|
||||||
|
|
||||||
|
# service
|
||||||
|
DROIDKLIPP_SERVICE_NAME = "adb_monitor"
|
||||||
|
|
||||||
|
# packages
|
||||||
|
DROIDKLIPP_REQUIRED_PACKAGES = {"adb", "tmux", "x11-utils"}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2026 Cody Dixon #
|
||||||
|
# #
|
||||||
|
# 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 subprocess import CalledProcessError, run
|
||||||
|
|
||||||
|
from components.klipperscreen import KLIPPERSCREEN_DIR, KLIPPERSCREEN_ENV_DIR
|
||||||
|
from core.logger import DialogType, Logger
|
||||||
|
from extensions.base_extension import BaseExtension
|
||||||
|
from extensions.droidklipp import (
|
||||||
|
DROIDKLIPP_APK_URL,
|
||||||
|
DROIDKLIPP_DEPLOYED_MONITOR,
|
||||||
|
DROIDKLIPP_DIR,
|
||||||
|
DROIDKLIPP_INSTALL_SCRIPT,
|
||||||
|
DROIDKLIPP_MONITOR_FILE,
|
||||||
|
DROIDKLIPP_REPO,
|
||||||
|
DROIDKLIPP_REQUIRED_PACKAGES,
|
||||||
|
DROIDKLIPP_SERVICE_NAME,
|
||||||
|
DROIDKLIPP_UNINSTALL_SCRIPT,
|
||||||
|
)
|
||||||
|
from utils.common import check_install_dependencies
|
||||||
|
from utils.fs_utils import check_file_exist, run_remove_routines
|
||||||
|
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
|
||||||
|
from utils.input_utils import get_confirm
|
||||||
|
from utils.sys_utils import cmd_sysctl_service
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyMethodMayBeStatic
|
||||||
|
class DroidKlippExtension(BaseExtension):
|
||||||
|
def install_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Installing DroidKlipp ...")
|
||||||
|
|
||||||
|
if not self._klipperscreen_exists():
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.WARNING,
|
||||||
|
[
|
||||||
|
"No KIAUH v6 KlipperScreen installation found!",
|
||||||
|
"DroidKlipp expects KlipperScreen at:",
|
||||||
|
f"● {KLIPPERSCREEN_DIR.joinpath('screen.py')}",
|
||||||
|
f"● {KLIPPERSCREEN_ENV_DIR.joinpath('bin/python')}",
|
||||||
|
"Install KlipperScreen first, then run this installer again.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.INFO,
|
||||||
|
[
|
||||||
|
"DroidKlipp requires the Android APK to be installed on your Android device:",
|
||||||
|
DROIDKLIPP_APK_URL,
|
||||||
|
"\n\n",
|
||||||
|
"The installer will configure ADB forwarding, udev rules, the DroidKlipp monitor, and WiFi fallback.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not get_confirm(
|
||||||
|
"Continue DroidKlipp installation?",
|
||||||
|
default_choice=True,
|
||||||
|
allow_go_back=True,
|
||||||
|
):
|
||||||
|
Logger.print_info("Exiting DroidKlipp installation ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
check_install_dependencies(DROIDKLIPP_REQUIRED_PACKAGES)
|
||||||
|
git_clone_wrapper(DROIDKLIPP_REPO, DROIDKLIPP_DIR)
|
||||||
|
run(["chmod", "+x", DROIDKLIPP_INSTALL_SCRIPT], check=True)
|
||||||
|
run([DROIDKLIPP_INSTALL_SCRIPT], check=True)
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.SUCCESS,
|
||||||
|
["DroidKlipp successfully installed!"],
|
||||||
|
center_content=True,
|
||||||
|
)
|
||||||
|
except CalledProcessError as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp installation:\n{e}")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp installation:\n{e}")
|
||||||
|
|
||||||
|
def update_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Updating DroidKlipp ...")
|
||||||
|
|
||||||
|
if not check_file_exist(DROIDKLIPP_DIR):
|
||||||
|
Logger.print_info("Extension does not seem to be installed! Skipping ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd_sysctl_service(DROIDKLIPP_SERVICE_NAME, "stop")
|
||||||
|
|
||||||
|
git_pull_wrapper(DROIDKLIPP_DIR)
|
||||||
|
|
||||||
|
if check_file_exist(DROIDKLIPP_MONITOR_FILE):
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"-m",
|
||||||
|
"755",
|
||||||
|
str(DROIDKLIPP_MONITOR_FILE),
|
||||||
|
str(DROIDKLIPP_DEPLOYED_MONITOR),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_sysctl_service(DROIDKLIPP_SERVICE_NAME, "start")
|
||||||
|
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.SUCCESS,
|
||||||
|
["DroidKlipp successfully updated!"],
|
||||||
|
center_content=True,
|
||||||
|
)
|
||||||
|
except CalledProcessError as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp update:\n{e}")
|
||||||
|
cmd_sysctl_service(DROIDKLIPP_SERVICE_NAME, "start")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp update:\n{e}")
|
||||||
|
cmd_sysctl_service(DROIDKLIPP_SERVICE_NAME, "start")
|
||||||
|
|
||||||
|
def remove_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Removing DroidKlipp ...")
|
||||||
|
|
||||||
|
if not check_file_exist(DROIDKLIPP_DIR):
|
||||||
|
Logger.print_info("Extension does not seem to be installed! Skipping ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not get_confirm(
|
||||||
|
"Do you really want to uninstall DroidKlipp?",
|
||||||
|
default_choice=True,
|
||||||
|
allow_go_back=True,
|
||||||
|
):
|
||||||
|
Logger.print_info("Exiting DroidKlipp uninstallation ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if check_file_exist(DROIDKLIPP_UNINSTALL_SCRIPT):
|
||||||
|
run(["chmod", "+x", DROIDKLIPP_UNINSTALL_SCRIPT], check=True)
|
||||||
|
run([DROIDKLIPP_UNINSTALL_SCRIPT], check=True)
|
||||||
|
run_remove_routines(DROIDKLIPP_DIR)
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.SUCCESS,
|
||||||
|
["DroidKlipp successfully removed!"],
|
||||||
|
center_content=True,
|
||||||
|
)
|
||||||
|
except CalledProcessError as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp removal:\n{e}")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.print_error(f"Error during DroidKlipp removal:\n{e}")
|
||||||
|
|
||||||
|
def _klipperscreen_exists(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
check_file_exist(KLIPPERSCREEN_DIR.joinpath("screen.py"))
|
||||||
|
and check_file_exist(KLIPPERSCREEN_ENV_DIR.joinpath("bin/python"))
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"index": 15,
|
||||||
|
"module": "droidklipp_extension",
|
||||||
|
"maintained_by": "CodeMasterCody3D",
|
||||||
|
"display_name": "DroidKlipp",
|
||||||
|
"description": [
|
||||||
|
"Use an Android device as a KlipperScreen display via ADB and DroidKlipp APK / XServer XSDL integration",
|
||||||
|
"- Automatic USB ADB forwarding",
|
||||||
|
"- Optional WiFi fallback",
|
||||||
|
"- Starts and monitors KlipperScreen on the Android X server"
|
||||||
|
],
|
||||||
|
"website": "https://github.com/CodeMasterCody3D/DroidKlipp-Android-APK/releases",
|
||||||
|
"repo": "https://github.com/CodeMasterCody3D/DroidKlipp",
|
||||||
|
"updates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# Copyright (C) 2026 Paul Sharman <github.com/PEEKYPAUL> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# It integrates Moongate for Klipper: #
|
||||||
|
# https://github.com/PEEKYPAUL/Moongate #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# repository
|
||||||
|
MOONGATE_REPO = "https://github.com/PEEKYPAUL/moongate.git"
|
||||||
|
MOONGATE_REPO_URL = "https://github.com/PEEKYPAUL/Moongate"
|
||||||
|
|
||||||
|
# directories
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parent
|
||||||
|
MOONGATE_DIR = Path.home().joinpath("moongate")
|
||||||
|
MOONGATE_PLUGIN_DIR = MOONGATE_DIR.joinpath("klipper-plugin")
|
||||||
|
|
||||||
|
# installer scripts shipped inside the cloned repo
|
||||||
|
MOONGATE_INSTALL_SCRIPT = MOONGATE_PLUGIN_DIR.joinpath("install.sh")
|
||||||
|
MOONGATE_UPDATE_SCRIPT = MOONGATE_PLUGIN_DIR.joinpath("update.sh")
|
||||||
|
MOONGATE_UNINSTALL_SCRIPT = MOONGATE_PLUGIN_DIR.joinpath("uninstall.sh")
|
||||||
|
|
||||||
|
# moonraker.conf sections the installer manages
|
||||||
|
MOONGATE_UPDATER_NAME = "update_manager moongate"
|
||||||
|
MOONGATE_CONFIG_SECTION = "moongate"
|
||||||
|
|
||||||
|
# default HTTP port the Mainsail/Fluidd UI is served on
|
||||||
|
MOONGATE_DEFAULT_PORT = 80
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"index": 16,
|
||||||
|
"module": "moongate_extension",
|
||||||
|
"maintained_by": "PEEKYPAUL",
|
||||||
|
"display_name": "Moongate for Klipper",
|
||||||
|
"description": [
|
||||||
|
"Pair this printer with the Moongate Android app for secure remote",
|
||||||
|
"access and print monitoring. Installs cloudflared, a Cloudflare",
|
||||||
|
"quick-tunnel and an EdDSA auth gate in front of Moonraker."
|
||||||
|
],
|
||||||
|
"repo": "https://github.com/PEEKYPAUL/Moongate",
|
||||||
|
"updates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# Copyright (C) 2026 Paul Sharman <github.com/PEEKYPAUL> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# It integrates Moongate for Klipper: #
|
||||||
|
# https://github.com/PEEKYPAUL/Moongate #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError, run
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from components.moonraker.moonraker import Moonraker
|
||||||
|
from core.instance_manager.instance_manager import InstanceManager
|
||||||
|
from core.logger import DialogType, Logger
|
||||||
|
from core.services.backup_service import BackupService
|
||||||
|
from extensions.base_extension import BaseExtension
|
||||||
|
from extensions.moongate import (
|
||||||
|
MOONGATE_CONFIG_SECTION,
|
||||||
|
MOONGATE_DEFAULT_PORT,
|
||||||
|
MOONGATE_DIR,
|
||||||
|
MOONGATE_INSTALL_SCRIPT,
|
||||||
|
MOONGATE_REPO,
|
||||||
|
MOONGATE_REPO_URL,
|
||||||
|
MOONGATE_UNINSTALL_SCRIPT,
|
||||||
|
MOONGATE_UPDATE_SCRIPT,
|
||||||
|
MOONGATE_UPDATER_NAME,
|
||||||
|
)
|
||||||
|
from utils.config_utils import remove_config_section
|
||||||
|
from utils.fs_utils import check_file_exist
|
||||||
|
from utils.git_utils import GitException, git_clone_wrapper, git_pull_wrapper
|
||||||
|
from utils.input_utils import get_confirm, get_number_input
|
||||||
|
from utils.instance_utils import get_instances
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyMethodMayBeStatic
|
||||||
|
class MoongateExtension(BaseExtension):
|
||||||
|
"""
|
||||||
|
Moongate ships a substantial, security-sensitive and idempotent installer
|
||||||
|
(cloudflared, two systemd services, an EdDSA auth proxy, a Moonraker host
|
||||||
|
rebind and a tightly-scoped Avahi sudoers entry). Rather than mirror all
|
||||||
|
of that in Python — where it would drift out of sync with upstream — this
|
||||||
|
extension does the KIAUH-idiomatic parts natively (instance discovery,
|
||||||
|
confirmation, moonraker.conf backup, the repo clone wired to the update
|
||||||
|
manager) and delegates the heavy lifting to Moongate's own scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def install_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Installing Moongate for Klipper ...")
|
||||||
|
|
||||||
|
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||||
|
if not mr_instances:
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.WARNING,
|
||||||
|
[
|
||||||
|
"No Moonraker instances found!",
|
||||||
|
"Moongate is a Moonraker component and needs Moonraker to be "
|
||||||
|
"installed first. Please install Moonraker, then try again.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Moongate is a single-printer integration. On a multi-instance host we
|
||||||
|
# target the first Moonraker instance and say so.
|
||||||
|
moonraker = mr_instances[0]
|
||||||
|
if len(mr_instances) > 1:
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.WARNING,
|
||||||
|
[
|
||||||
|
"Multiple Moonraker instances detected.",
|
||||||
|
"Moongate currently supports a single-printer setup. The "
|
||||||
|
f"instance '{moonraker.data_dir.name}' will be used.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._confirm_install():
|
||||||
|
Logger.print_info("Installation aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
port = get_number_input(
|
||||||
|
"HTTP port your Mainsail/Fluidd UI is served on",
|
||||||
|
min_value=1,
|
||||||
|
max_value=65535,
|
||||||
|
default=MOONGATE_DEFAULT_PORT,
|
||||||
|
)
|
||||||
|
if port is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._clone_or_update_repo()
|
||||||
|
|
||||||
|
BackupService().backup_moonraker_conf()
|
||||||
|
|
||||||
|
# Hand off to Moongate's own installer. It is idempotent,
|
||||||
|
# non-interactive and env-driven: it installs cloudflared, adds the
|
||||||
|
# two systemd services, patches moonraker.conf and restarts
|
||||||
|
# Moonraker + Klipper itself.
|
||||||
|
self._run_script(
|
||||||
|
MOONGATE_INSTALL_SCRIPT,
|
||||||
|
moonraker,
|
||||||
|
extra_env={"MOONGATE_PORT": str(port)},
|
||||||
|
)
|
||||||
|
except (GitException, CalledProcessError, OSError) as e:
|
||||||
|
Logger.print_error(f"Error during Moongate installation:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.SUCCESS,
|
||||||
|
[
|
||||||
|
"Moongate installed successfully!",
|
||||||
|
"\n\n",
|
||||||
|
"Next steps:",
|
||||||
|
"● Install the Moongate app on your Android device.",
|
||||||
|
"● Run MOONGATE_PAIR in the Klipper console (or open the pair "
|
||||||
|
"page printed above) and scan the QR code.",
|
||||||
|
"● Updates from now on: Mainsail/Fluidd > Software Updates > Moongate.",
|
||||||
|
],
|
||||||
|
margin_bottom=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Updating Moongate for Klipper ...")
|
||||||
|
|
||||||
|
if not check_file_exist(MOONGATE_DIR.joinpath(".git")):
|
||||||
|
Logger.print_info("Moongate does not seem to be installed. Skipping ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||||
|
if not mr_instances:
|
||||||
|
Logger.print_warn("No Moonraker instance found. Skipping ...")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
git_pull_wrapper(MOONGATE_DIR)
|
||||||
|
self._run_script(MOONGATE_UPDATE_SCRIPT, mr_instances[0])
|
||||||
|
InstanceManager.restart_all(mr_instances)
|
||||||
|
except (GitException, CalledProcessError, OSError) as e:
|
||||||
|
Logger.print_error(f"Error during Moongate update:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
Logger.print_ok("Moongate updated successfully.", end="\n\n")
|
||||||
|
|
||||||
|
def remove_extension(self, **kwargs) -> None:
|
||||||
|
Logger.print_status("Removing Moongate for Klipper ...")
|
||||||
|
|
||||||
|
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||||
|
|
||||||
|
if not get_confirm(
|
||||||
|
"This removes Moongate, cloudflared, both systemd services and all "
|
||||||
|
"Moongate config. Continue?",
|
||||||
|
default_choice=True,
|
||||||
|
allow_go_back=True,
|
||||||
|
):
|
||||||
|
Logger.print_info("Removal aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Preferred path: delegate to Moongate's own uninstaller, which stops
|
||||||
|
# and removes the services, cleans moonraker.conf, restores its backup
|
||||||
|
# and restarts Moonraker. MOONGATE_YES=1 makes it non-interactive
|
||||||
|
# (KIAUH already collected the confirmation above).
|
||||||
|
if check_file_exist(MOONGATE_UNINSTALL_SCRIPT):
|
||||||
|
try:
|
||||||
|
BackupService().backup_moonraker_conf()
|
||||||
|
target = mr_instances[0] if mr_instances else None
|
||||||
|
self._run_script(
|
||||||
|
MOONGATE_UNINSTALL_SCRIPT,
|
||||||
|
target,
|
||||||
|
extra_env={"MOONGATE_YES": "1"},
|
||||||
|
)
|
||||||
|
Logger.print_ok("Moongate removed successfully.")
|
||||||
|
return
|
||||||
|
except (CalledProcessError, OSError) as e:
|
||||||
|
Logger.print_error(f"Error during Moongate removal:\n{e}")
|
||||||
|
# fall through to a best-effort native cleanup
|
||||||
|
|
||||||
|
# Fallback: the upstream uninstaller is gone (repo already deleted).
|
||||||
|
# Do a best-effort native cleanup so moonraker.conf is left consistent.
|
||||||
|
Logger.print_warn(
|
||||||
|
"Moongate uninstaller not found — doing a best-effort cleanup. You "
|
||||||
|
"may need to remove cloudflared and the moongate-* systemd services "
|
||||||
|
"manually."
|
||||||
|
)
|
||||||
|
if mr_instances:
|
||||||
|
BackupService().backup_moonraker_conf()
|
||||||
|
remove_config_section(MOONGATE_UPDATER_NAME, mr_instances)
|
||||||
|
remove_config_section(MOONGATE_CONFIG_SECTION, mr_instances)
|
||||||
|
InstanceManager.restart_all(mr_instances)
|
||||||
|
Logger.print_ok("Moongate configuration removed.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# helpers #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def _confirm_install(self) -> bool:
|
||||||
|
Logger.print_dialog(
|
||||||
|
DialogType.ATTENTION,
|
||||||
|
[
|
||||||
|
"Moongate pairs this printer with the Moongate Android app for "
|
||||||
|
"secure remote access and print monitoring.",
|
||||||
|
"\n\n",
|
||||||
|
"This is a heavier install than most extensions. It will:",
|
||||||
|
"● clone the Moongate repo to ~/moongate",
|
||||||
|
"● add the Moongate component to Moonraker and register it with "
|
||||||
|
"the update manager",
|
||||||
|
"● install cloudflared and open a Cloudflare quick-tunnel",
|
||||||
|
"● add two systemd services: moongate-authproxy + moongate-tunnel",
|
||||||
|
"● bind Moonraker to 127.0.0.1 (the auth proxy fronts the tunnel)",
|
||||||
|
"● add a tightly-scoped Avahi sudoers entry for LAN discovery",
|
||||||
|
"\n\n",
|
||||||
|
"Remote access relies on cloud infrastructure operated by the "
|
||||||
|
"Moongate author. Moongate is licensed under PolyForm "
|
||||||
|
"Noncommercial 1.0.0 (non-commercial use only).",
|
||||||
|
MOONGATE_REPO_URL,
|
||||||
|
],
|
||||||
|
margin_bottom=1,
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
get_confirm(
|
||||||
|
"Continue Moongate installation?",
|
||||||
|
default_choice=True,
|
||||||
|
allow_go_back=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clone_or_update_repo(self) -> None:
|
||||||
|
if check_file_exist(MOONGATE_DIR.joinpath(".git")):
|
||||||
|
git_pull_wrapper(MOONGATE_DIR)
|
||||||
|
else:
|
||||||
|
git_clone_wrapper(MOONGATE_REPO, MOONGATE_DIR)
|
||||||
|
|
||||||
|
def _run_script(
|
||||||
|
self,
|
||||||
|
script: Path,
|
||||||
|
moonraker: Moonraker | None,
|
||||||
|
extra_env: Dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if moonraker is not None:
|
||||||
|
env["MOONRAKER_DIR"] = moonraker.moonraker_dir.as_posix()
|
||||||
|
env["PRINTER_DATA"] = moonraker.data_dir.as_posix()
|
||||||
|
if extra_env:
|
||||||
|
env.update(extra_env)
|
||||||
|
run(["bash", script.as_posix()], env=env, check=True)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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"})
|
||||||
+11
-6
@@ -9,6 +9,7 @@
|
|||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from core.cli import run_cli
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from core.menus.main_menu import MainMenu
|
from core.menus.main_menu import MainMenu
|
||||||
from core.settings.kiauh_settings import KiauhSettings
|
from core.settings.kiauh_settings import KiauhSettings
|
||||||
@@ -21,12 +22,16 @@ def ensure_encoding() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
try:
|
rc = run_cli()
|
||||||
KiauhSettings()
|
if rc == -1:
|
||||||
ensure_encoding()
|
try:
|
||||||
MainMenu().run()
|
KiauhSettings()
|
||||||
except KeyboardInterrupt:
|
ensure_encoding()
|
||||||
Logger.print_ok("\nHappy printing!\n", prefix=False)
|
MainMenu().run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
Logger.print_ok("\nHappy printing!\n", prefix=False)
|
||||||
|
elif rc > 0:
|
||||||
|
sys.exit(rc)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from components.moonraker.services.moonraker_setup_service import (
|
|||||||
from core.instance_manager.instance_manager import InstanceManager
|
from core.instance_manager.instance_manager import InstanceManager
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from core.services.backup_service import BackupService
|
from core.services.backup_service import BackupService
|
||||||
|
from core.settings.kiauh_settings import KiauhSettings
|
||||||
from utils.git_utils import GitException, git_clone_wrapper
|
from utils.git_utils import GitException, git_clone_wrapper
|
||||||
from utils.instance_utils import get_instances
|
from utils.instance_utils import get_instances
|
||||||
from utils.sys_utils import (
|
from utils.sys_utils import (
|
||||||
@@ -47,6 +48,11 @@ class RepoSwitchFailedException(Exception):
|
|||||||
def run_switch_repo_routine(
|
def run_switch_repo_routine(
|
||||||
name: Literal["klipper", "moonraker"], repo_url: str, branch: str
|
name: Literal["klipper", "moonraker"], repo_url: str, branch: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if name not in ("klipper", "moonraker"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid name: {name!r}. Must be 'klipper' or 'moonraker'."
|
||||||
|
)
|
||||||
|
|
||||||
repo_dir: Path = KLIPPER_DIR if name == "klipper" else MOONRAKER_DIR
|
repo_dir: Path = KLIPPER_DIR if name == "klipper" else MOONRAKER_DIR
|
||||||
env_dir: Path = KLIPPER_ENV_DIR if name == "klipper" else MOONRAKER_ENV_DIR
|
env_dir: Path = KLIPPER_ENV_DIR if name == "klipper" else MOONRAKER_ENV_DIR
|
||||||
req_file = KLIPPER_REQ_FILE if name == "klipper" else MOONRAKER_REQ_FILE
|
req_file = KLIPPER_REQ_FILE if name == "klipper" else MOONRAKER_REQ_FILE
|
||||||
@@ -89,7 +95,16 @@ def run_switch_repo_routine(
|
|||||||
|
|
||||||
# step 6: recreate python virtualenv
|
# step 6: recreate python virtualenv
|
||||||
Logger.print_status(f"Recreating {_type.__name__} virtualenv ...")
|
Logger.print_status(f"Recreating {_type.__name__} virtualenv ...")
|
||||||
if not create_python_venv(env_dir, force=True):
|
|
||||||
|
settings = KiauhSettings()
|
||||||
|
if name == "klipper":
|
||||||
|
use_python_binary = settings.klipper.use_python_binary
|
||||||
|
elif name == "moonraker":
|
||||||
|
use_python_binary = settings.moonraker.use_python_binary
|
||||||
|
|
||||||
|
if not create_python_venv(
|
||||||
|
env_dir, force=True, use_python_binary=use_python_binary
|
||||||
|
):
|
||||||
raise GitException(f"Failed to recreate virtualenv for {_type.__name__}")
|
raise GitException(f"Failed to recreate virtualenv for {_type.__name__}")
|
||||||
else:
|
else:
|
||||||
install_python_requirements(env_dir, req_file)
|
install_python_requirements(env_dir, req_file)
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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)
|
||||||
@@ -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 __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
|
||||||
@@ -36,13 +36,15 @@ from utils.sys_utils import (
|
|||||||
update_system_package_lists,
|
update_system_package_lists,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from kiauh import PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
def get_kiauh_version() -> str:
|
def get_kiauh_version() -> str:
|
||||||
"""
|
"""
|
||||||
Helper method to get the current KIAUH version by reading the latest tag
|
Helper method to get the current KIAUH version by reading the latest tag
|
||||||
:return: string of the latest tag or a default value if no tags exist
|
:return: string of the latest tag or a default value if no tags exist
|
||||||
"""
|
"""
|
||||||
tags: List[str] = get_local_tags(Path(__file__).parent.parent)
|
tags: List[str] = get_local_tags(PROJECT_ROOT)
|
||||||
if tags:
|
if tags:
|
||||||
return tags[-1]
|
return tags[-1]
|
||||||
else:
|
else:
|
||||||
@@ -87,6 +89,8 @@ def check_install_dependencies(
|
|||||||
Logger.print_info("The following packages need installation:")
|
Logger.print_info("The following packages need installation:")
|
||||||
for r in requirements:
|
for r in requirements:
|
||||||
print(Color.apply(f"● {r}", Color.CYAN))
|
print(Color.apply(f"● {r}", Color.CYAN))
|
||||||
|
# Installing against stale or missing package metadata is unsafe, so abort
|
||||||
|
# here instead of swallowing the error like the update menu does.
|
||||||
update_system_package_lists(silent=False)
|
update_system_package_lists(silent=False)
|
||||||
install_system_packages(requirements)
|
install_system_packages(requirements)
|
||||||
|
|
||||||
|
|||||||
+31
-9
@@ -12,15 +12,34 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import DEVNULL, PIPE, CalledProcessError, call, check_output, run
|
from subprocess import DEVNULL, PIPE, CalledProcessError
|
||||||
from typing import List
|
from typing import List
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from core import backends
|
||||||
from core.decorators import deprecated
|
from core.decorators import deprecated
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
|
|
||||||
|
# Delegate to the shared backends module so tests can substitute
|
||||||
|
# command_runner/filesystem from one location instead of patching module globals.
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: str | List[str], **kwargs) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command through the shared command runner."""
|
||||||
|
return backends.command_runner.run(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def check_output(cmd: str | List[str], **kwargs) -> str | bytes:
|
||||||
|
"""Run a command and return its output through the shared command runner."""
|
||||||
|
return backends.command_runner.check_output(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def call(cmd: str | List[str], **kwargs) -> int:
|
||||||
|
"""Run a command and return its exit code through the shared command runner."""
|
||||||
|
return backends.command_runner.call(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def check_file_exist(file_path: Path, sudo=False) -> bool:
|
def check_file_exist(file_path: Path, sudo=False) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -103,14 +122,14 @@ def remove_file(file_path: Path, sudo=False) -> None:
|
|||||||
|
|
||||||
def run_remove_routines(file: Path) -> bool:
|
def run_remove_routines(file: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
if not file.is_symlink() and not file.exists():
|
if not backends.filesystem.is_symlink(file) and not backends.filesystem.exists(file):
|
||||||
Logger.print_info(f"File '{file}' does not exist. Skipped ...")
|
Logger.print_info(f"File '{file}' does not exist. Skipped ...")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if file.is_dir():
|
if backends.filesystem.is_dir(file):
|
||||||
shutil.rmtree(file)
|
backends.filesystem.rmtree(file)
|
||||||
elif file.is_file() or file.is_symlink():
|
elif backends.filesystem.is_file(file) or backends.filesystem.is_symlink(file):
|
||||||
file.unlink()
|
backends.filesystem.unlink(file)
|
||||||
else:
|
else:
|
||||||
Logger.print_error(f"File '{file}' is neither a file nor a directory!")
|
Logger.print_error(f"File '{file}' is neither a file nor a directory!")
|
||||||
return False
|
return False
|
||||||
@@ -127,6 +146,9 @@ def run_remove_routines(file: Path) -> bool:
|
|||||||
Logger.print_error(f"Error deleting '{file}' with sudo:\n{e}")
|
Logger.print_error(f"Error deleting '{file}' with sudo:\n{e}")
|
||||||
Logger.print_error("Remove this directory manually!")
|
Logger.print_error("Remove this directory manually!")
|
||||||
return False
|
return False
|
||||||
|
# Direct and sudo removal both failed without raising; return a boolean so
|
||||||
|
# callers get a predictable result.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def unzip(filepath: Path, target_dir: Path) -> None:
|
def unzip(filepath: Path, target_dir: Path) -> None:
|
||||||
@@ -143,9 +165,9 @@ def unzip(filepath: Path, target_dir: Path) -> None:
|
|||||||
def create_folders(dirs: List[Path]) -> None:
|
def create_folders(dirs: List[Path]) -> None:
|
||||||
try:
|
try:
|
||||||
for _dir in dirs:
|
for _dir in dirs:
|
||||||
if _dir.exists():
|
if backends.filesystem.exists(_dir):
|
||||||
continue
|
continue
|
||||||
_dir.mkdir(exist_ok=True)
|
backends.filesystem.mkdir(_dir, exist_ok=True)
|
||||||
Logger.print_ok(f"Created directory '{_dir}'!")
|
Logger.print_ok(f"Created directory '{_dir}'!")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
Logger.print_error(f"Error creating directories: {e}")
|
Logger.print_error(f"Error creating directories: {e}")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def git_pull_wrapper(target_dir: Path) -> None:
|
|||||||
Logger.print_status("Updating repository ...")
|
Logger.print_status("Updating repository ...")
|
||||||
try:
|
try:
|
||||||
git_cmd_pull(target_dir)
|
git_cmd_pull(target_dir)
|
||||||
except CalledProcessError:
|
except (CalledProcessError, GitException):
|
||||||
log = "An unexpected error occured during updating the repository."
|
log = "An unexpected error occured during updating the repository."
|
||||||
Logger.print_error(log)
|
Logger.print_error(log)
|
||||||
return
|
return
|
||||||
@@ -102,6 +102,9 @@ def get_current_branch(repo: Path) -> str | None:
|
|||||||
:param repo: Path to the local Git repository
|
:param repo: Path to the local Git repository
|
||||||
:return: Current branch or None if not determinable
|
:return: Current branch or None if not determinable
|
||||||
"""
|
"""
|
||||||
|
if not repo.exists() or not repo.joinpath(".git").exists():
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cmd = ["git", "branch", "--show-current"]
|
cmd = ["git", "branch", "--show-current"]
|
||||||
result: str = check_output(cmd, stderr=DEVNULL, cwd=repo).decode(
|
result: str = check_output(cmd, stderr=DEVNULL, cwd=repo).decode(
|
||||||
@@ -109,7 +112,7 @@ def get_current_branch(repo: Path) -> str | None:
|
|||||||
)
|
)
|
||||||
return result.strip() if result else None
|
return result.strip() if result else None
|
||||||
|
|
||||||
except CalledProcessError:
|
except (CalledProcessError, FileNotFoundError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +123,8 @@ def get_local_tags(repo_path: Path, _filter: str | None = None) -> List[str]:
|
|||||||
:param _filter: Optional filter to filter the tags by
|
:param _filter: Optional filter to filter the tags by
|
||||||
:return: List of tags
|
:return: List of tags
|
||||||
"""
|
"""
|
||||||
|
if not repo_path.exists() or not repo_path.joinpath(".git").is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
def parse_version(version: str) -> tuple:
|
def parse_version(version: str) -> tuple:
|
||||||
# Remove 'v' prefix if present
|
# Remove 'v' prefix if present
|
||||||
@@ -337,6 +342,11 @@ def git_cmd_checkout(branch: str | None, target_dir: Path) -> None:
|
|||||||
if branch is None:
|
if branch is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not target_dir.exists() or not target_dir.joinpath(".git").exists():
|
||||||
|
log = f"'{target_dir}' is not a valid git repository."
|
||||||
|
Logger.print_error(log)
|
||||||
|
raise GitException(log)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
command = ["git", "checkout", f"{branch}"]
|
command = ["git", "checkout", f"{branch}"]
|
||||||
run(command, cwd=target_dir, check=True)
|
run(command, cwd=target_dir, check=True)
|
||||||
@@ -349,6 +359,11 @@ def git_cmd_checkout(branch: str | None, target_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def git_cmd_pull(target_dir: Path) -> None:
|
def git_cmd_pull(target_dir: Path) -> None:
|
||||||
|
if not target_dir.exists() or not target_dir.joinpath(".git").exists():
|
||||||
|
log = f"'{target_dir}' is not a valid git repository."
|
||||||
|
Logger.print_error(log)
|
||||||
|
raise GitException(log)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
command = ["git", "pull"]
|
command = ["git", "pull"]
|
||||||
run(command, cwd=target_dir, check=True)
|
run(command, cwd=target_dir, check=True)
|
||||||
@@ -359,6 +374,11 @@ def git_cmd_pull(target_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def rollback_repository(repo_dir: Path, instance: Type[InstanceType]) -> None:
|
def rollback_repository(repo_dir: Path, instance: Type[InstanceType]) -> None:
|
||||||
|
if not repo_dir.exists() or not repo_dir.joinpath(".git").exists():
|
||||||
|
log = f"'{repo_dir}' is not a valid git repository."
|
||||||
|
Logger.print_error(log)
|
||||||
|
raise GitException(log)
|
||||||
|
|
||||||
q1 = "How many commits do you want to roll back"
|
q1 = "How many commits do you want to roll back"
|
||||||
amount = get_number_input(q1, 1, allow_go_back=True)
|
amount = get_number_input(q1, 1, allow_go_back=True)
|
||||||
|
|
||||||
@@ -394,7 +414,7 @@ def get_repo_url(repo_dir: Path) -> str | None:
|
|||||||
:param repo_dir: Path to the git repository
|
:param repo_dir: Path to the git repository
|
||||||
:return: URL of the remote repository or None if not found
|
:return: URL of the remote repository or None if not found
|
||||||
"""
|
"""
|
||||||
if not repo_dir.exists():
|
if not repo_dir.exists() or not repo_dir.joinpath(".git").exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ from typing import TypeVar
|
|||||||
from components.klipper.klipper import Klipper
|
from components.klipper.klipper import Klipper
|
||||||
from components.moonraker.moonraker import Moonraker
|
from components.moonraker.moonraker import Moonraker
|
||||||
from extensions.obico.moonraker_obico import MoonrakerObico
|
from extensions.obico.moonraker_obico import MoonrakerObico
|
||||||
from extensions.octoeverywhere.octoeverywhere import Octoeverywhere
|
|
||||||
from extensions.octoapp.octoapp import Octoapp
|
from extensions.octoapp.octoapp import Octoapp
|
||||||
from extensions.telegram_bot.moonraker_telegram_bot import MoonrakerTelegramBot
|
from extensions.octoeverywhere.octoeverywhere import Octoeverywhere
|
||||||
from extensions.octoprint.octoprint import Octoprint
|
from extensions.octoprint.octoprint import Octoprint
|
||||||
|
from extensions.telegram_bot.moonraker_telegram_bot import MoonrakerTelegramBot
|
||||||
|
|
||||||
InstanceType = TypeVar(
|
InstanceType = TypeVar(
|
||||||
"InstanceType",
|
"InstanceType",
|
||||||
|
|||||||
+48
-10
@@ -13,14 +13,16 @@ import re
|
|||||||
import select
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, check_output, run
|
from subprocess import DEVNULL, PIPE, CalledProcessError, Popen
|
||||||
from typing import List, Literal, Set, Tuple
|
from typing import List, Literal, Set, Tuple
|
||||||
|
|
||||||
|
from core import backends
|
||||||
from core.constants import SYSTEMD
|
from core.constants import SYSTEMD
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from utils.fs_utils import check_file_exist, remove_with_sudo
|
from utils.fs_utils import check_file_exist, remove_with_sudo
|
||||||
@@ -38,6 +40,29 @@ SysCtlServiceAction = Literal[
|
|||||||
]
|
]
|
||||||
SysCtlManageAction = Literal["daemon-reload", "reset-failed"]
|
SysCtlManageAction = Literal["daemon-reload", "reset-failed"]
|
||||||
|
|
||||||
|
# Delegate to the shared backends module so tests can substitute command_runner
|
||||||
|
# from one location instead of patching module globals.
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: str | List[str], **kwargs) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command through the shared command runner."""
|
||||||
|
return backends.command_runner.run(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def check_output(cmd: str | List[str], **kwargs) -> str | bytes:
|
||||||
|
"""Run a command and return its output through the shared command runner."""
|
||||||
|
return backends.command_runner.check_output(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def call(cmd: str | List[str], **kwargs) -> int:
|
||||||
|
"""Run a command and return its exit code through the shared command runner."""
|
||||||
|
return backends.command_runner.call(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def popen(cmd: str | List[str], **kwargs) -> Popen:
|
||||||
|
"""Start a process through the shared command runner."""
|
||||||
|
return backends.command_runner.popen(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class VenvCreationFailedException(Exception):
|
class VenvCreationFailedException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -95,7 +120,8 @@ def create_python_venv(
|
|||||||
target: Path,
|
target: Path,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
allow_access_to_system_site_packages: bool = False,
|
allow_access_to_system_site_packages: bool = False,
|
||||||
use_python_binary: str | None = None
|
use_python_binary: str | None = None,
|
||||||
|
interactive: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Create a python 3 virtualenv at the provided target destination.
|
Create a python 3 virtualenv at the provided target destination.
|
||||||
@@ -105,6 +131,9 @@ def create_python_venv(
|
|||||||
:param force: Force recreation of the virtualenv
|
:param force: Force recreation of the virtualenv
|
||||||
:param allow_access_to_system_site_packages: give the virtual environment access to the system site-packages dir
|
:param allow_access_to_system_site_packages: give the virtual environment access to the system site-packages dir
|
||||||
:param use_python_binary: allows to override default python binary
|
:param use_python_binary: allows to override default python binary
|
||||||
|
:param interactive: When False (headless), an existing venv is left untouched
|
||||||
|
instead of prompting for confirmation: a non-interactive run must never
|
||||||
|
destroy a working venv, and must never block on ``read``.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
Logger.print_status("Set up Python virtual environment ...")
|
Logger.print_status("Set up Python virtual environment ...")
|
||||||
@@ -116,7 +145,7 @@ def create_python_venv(
|
|||||||
) if allow_access_to_system_site_packages else None
|
) if allow_access_to_system_site_packages else None
|
||||||
|
|
||||||
n = 2
|
n = 2
|
||||||
while(n > 0):
|
while n > 0:
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
try:
|
try:
|
||||||
run(cmd, check=True)
|
run(cmd, check=True)
|
||||||
@@ -131,11 +160,20 @@ def create_python_venv(
|
|||||||
# but the function should still behave correctly
|
# but the function should still behave correctly
|
||||||
Logger.print_error("Virtualenv still exists after deletion.")
|
Logger.print_error("Virtualenv still exists after deletion.")
|
||||||
return False
|
return False
|
||||||
if not force and not get_confirm(
|
if not force:
|
||||||
"Virtualenv already exists. Re-create?", default_choice=False
|
if not interactive:
|
||||||
):
|
# Headless mode must never destroy an existing venv or block
|
||||||
Logger.print_info("Skipping re-creation of virtualenv ...")
|
# on input; skip it so requirements are only installed into
|
||||||
return False
|
# freshly created environments.
|
||||||
|
Logger.print_info(
|
||||||
|
"Virtualenv already exists; skipping re-creation ..."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not get_confirm(
|
||||||
|
"Virtualenv already exists. Re-create?", default_choice=False
|
||||||
|
):
|
||||||
|
Logger.print_info("Skipping re-creation of virtualenv ...")
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
@@ -165,7 +203,7 @@ def update_python_pip(target: Path) -> None:
|
|||||||
if result.returncode != 0 or result.stderr:
|
if result.returncode != 0 or result.stderr:
|
||||||
Logger.print_error(f"{result.stderr}", False)
|
Logger.print_error(f"{result.stderr}", False)
|
||||||
Logger.print_error("Updating pip failed!")
|
Logger.print_error("Updating pip failed!")
|
||||||
return
|
raise RuntimeError("Updating pip failed!")
|
||||||
|
|
||||||
Logger.print_ok("Updating pip successful!")
|
Logger.print_ok("Updating pip successful!")
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -268,7 +306,7 @@ def update_system_package_lists(silent: bool, rls_info_change=False) -> None:
|
|||||||
if result.returncode != 0 or result.stderr:
|
if result.returncode != 0 or result.stderr:
|
||||||
Logger.print_error(f"{result.stderr}", False)
|
Logger.print_error(f"{result.stderr}", False)
|
||||||
Logger.print_error("Updating system package list failed!")
|
Logger.print_error("Updating system package list failed!")
|
||||||
return
|
raise RuntimeError("Updating system package list failed!")
|
||||||
|
|
||||||
Logger.print_ok("System package list update successful!")
|
Logger.print_ok("System package list update successful!")
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Set
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from core.constants import GLOBAL_DEPS
|
||||||
|
from utils.common import (
|
||||||
|
check_install_dependencies,
|
||||||
|
convert_camelcase_to_kebabcase,
|
||||||
|
get_current_date,
|
||||||
|
get_install_status,
|
||||||
|
get_kiauh_version,
|
||||||
|
moonraker_exists,
|
||||||
|
trunc_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetKiauhVersion:
|
||||||
|
def test_uses_project_root(self, monkeypatch) -> None:
|
||||||
|
expected_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
captured: List[Path] = []
|
||||||
|
|
||||||
|
def fake_get_local_tags(path: Path, _filter: str | None = None) -> List[str]:
|
||||||
|
captured.append(path)
|
||||||
|
return ["v6.3.0", "v6.3.1"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.common.get_local_tags", fake_get_local_tags)
|
||||||
|
result = get_kiauh_version()
|
||||||
|
|
||||||
|
assert captured == [expected_root]
|
||||||
|
assert result == "v6.3.1"
|
||||||
|
|
||||||
|
def test_fallback_when_no_tags(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("utils.common.get_local_tags", lambda *_a, **_k: [])
|
||||||
|
assert get_kiauh_version() == "v?.?.?"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertCamelcaseToKebabcase:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name,expected",
|
||||||
|
[
|
||||||
|
("Klipper", "klipper"),
|
||||||
|
("Moonraker", "moonraker"),
|
||||||
|
("MoonrakerObico", "moonraker-obico"),
|
||||||
|
("HTTPResponse", "h-t-t-p-response"),
|
||||||
|
("already", "already"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_converts(self, name: str, expected: str) -> None:
|
||||||
|
assert convert_camelcase_to_kebabcase(name) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentDate:
|
||||||
|
def test_returns_formatted_values(self) -> None:
|
||||||
|
result = get_current_date()
|
||||||
|
now = datetime.today()
|
||||||
|
|
||||||
|
assert set(result.keys()) == {"date", "time"}
|
||||||
|
assert result["date"] == now.strftime("%Y%m%d")
|
||||||
|
assert result["time"] == now.strftime("%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckInstallDependencies:
|
||||||
|
def test_with_global_and_custom(self, monkeypatch) -> None:
|
||||||
|
checked: Set[str] = set()
|
||||||
|
updated: List[bool] = []
|
||||||
|
installed_pkgs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_check_package_install(deps: Set[str]) -> List[str]:
|
||||||
|
checked.update(deps)
|
||||||
|
return ["extra-pkg"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.check_package_install", fake_check_package_install
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.update_system_package_lists",
|
||||||
|
lambda silent: updated.append(silent),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.install_system_packages",
|
||||||
|
lambda pkgs: installed_pkgs.append(pkgs),
|
||||||
|
)
|
||||||
|
|
||||||
|
check_install_dependencies({"custom-pkg"}, include_global=True)
|
||||||
|
|
||||||
|
assert "custom-pkg" in checked
|
||||||
|
assert all(dep in checked for dep in GLOBAL_DEPS)
|
||||||
|
assert updated == [False]
|
||||||
|
assert installed_pkgs == [["extra-pkg"]]
|
||||||
|
|
||||||
|
def test_no_requirements(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("utils.common.check_package_install", lambda *_a, **_k: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.update_system_package_lists",
|
||||||
|
lambda *a, **k: pytest.fail("should not update when nothing to install"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.install_system_packages",
|
||||||
|
lambda *a, **k: pytest.fail("should not install when nothing to install"),
|
||||||
|
)
|
||||||
|
|
||||||
|
check_install_dependencies({"pkg"})
|
||||||
|
|
||||||
|
def test_propagates_runtime_error_from_package_list_update(
|
||||||
|
self, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
# Installing dependencies must propagate apt update failures rather than
|
||||||
|
# swallow them, because continuing with broken package metadata is unsafe.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.check_package_install",
|
||||||
|
lambda *_a, **_k: ["missing-pkg"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise(*_a, **_k):
|
||||||
|
raise RuntimeError("apt-get update failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.common.update_system_package_lists", _raise)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.install_system_packages",
|
||||||
|
lambda *_a, **_k: pytest.fail("should not install on broken apt update"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
check_install_dependencies({"pkg"})
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInstanceType:
|
||||||
|
def __init__(self, suffix: str):
|
||||||
|
self.suffix = suffix
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(other, _FakeInstanceType) and self.suffix == other.suffix
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetInstallStatus:
|
||||||
|
def test_not_installed(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
env = tmp_path / "env"
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.common.get_current_branch", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.common.get_repo_name", lambda *_a, **_k: (None, None))
|
||||||
|
monkeypatch.setattr("utils.common.get_repo_url", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.common.get_local_commit", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.common.get_remote_commit", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.instance_utils.get_instances", lambda *_a, **_k: [])
|
||||||
|
|
||||||
|
status = get_install_status(repo, env, _FakeInstanceType)
|
||||||
|
|
||||||
|
assert status.status == 0
|
||||||
|
assert status.instances == 0
|
||||||
|
|
||||||
|
def test_fully_installed(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
env = tmp_path / "env"
|
||||||
|
repo.mkdir()
|
||||||
|
env.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
extra_file = tmp_path / "extra"
|
||||||
|
extra_file.write_text("x")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.instance_utils.get_instances", lambda *_a, **_k: [_FakeInstanceType("")]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.common.get_current_branch", lambda *_a, **_k: "main")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.get_repo_name", lambda *_a, **_k: ("dw-0", "kiauh")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.common.get_repo_url", lambda *_a, **_k: "https://github.com/dw-0/kiauh"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.common.get_local_commit", lambda *_a, **_k: "abc")
|
||||||
|
monkeypatch.setattr("utils.common.get_remote_commit", lambda *_a, **_k: "def")
|
||||||
|
|
||||||
|
status = get_install_status(repo, env, _FakeInstanceType, files=[extra_file])
|
||||||
|
|
||||||
|
assert status.status == 2
|
||||||
|
assert status.instances == 1
|
||||||
|
assert status.owner == "dw-0"
|
||||||
|
assert status.repo == "kiauh"
|
||||||
|
assert status.branch == "main"
|
||||||
|
assert status.local == "abc"
|
||||||
|
assert status.remote == "def"
|
||||||
|
|
||||||
|
def test_incomplete(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
env = tmp_path / "env"
|
||||||
|
repo.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.instance_utils.get_instances", lambda *_a, **_k: [_FakeInstanceType("")]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.common.get_current_branch", lambda *_a, **_k: "main")
|
||||||
|
monkeypatch.setattr("utils.common.get_repo_name", lambda *_a, **_k: (None, None))
|
||||||
|
monkeypatch.setattr("utils.common.get_repo_url", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.common.get_local_commit", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("utils.common.get_remote_commit", lambda *_a, **_k: None)
|
||||||
|
|
||||||
|
status = get_install_status(repo, env, _FakeInstanceType)
|
||||||
|
|
||||||
|
assert status.status == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoonrakerExists:
|
||||||
|
def test_returns_instances(self, monkeypatch) -> None:
|
||||||
|
fake = object()
|
||||||
|
monkeypatch.setattr("utils.common.get_instances", lambda *_a, **_k: [fake])
|
||||||
|
assert moonraker_exists() == [fake]
|
||||||
|
|
||||||
|
def test_warns_when_none(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("utils.common.get_instances", lambda *_a, **_k: [])
|
||||||
|
assert moonraker_exists("SomeInstaller") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncString:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,length,expected",
|
||||||
|
[
|
||||||
|
("short", 10, "short"),
|
||||||
|
("exactly seven", 20, "exactly seven"),
|
||||||
|
("much longer string", 10, "much lo..."),
|
||||||
|
("abcdef", 5, "ab..."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_truncates(self, value: str, length: int, expected: str) -> None:
|
||||||
|
assert trunc_string(value, length) == expected
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from utils.config_utils import (
|
||||||
|
add_config_section,
|
||||||
|
add_config_section_at_top,
|
||||||
|
remove_config_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInstance:
|
||||||
|
def __init__(self, cfg_file: Path):
|
||||||
|
self.cfg_file = cfg_file
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cfg(path: Path, content: str) -> None:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddConfigSection:
|
||||||
|
def test_creates_section_and_options(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "printer.cfg"
|
||||||
|
_write_cfg(cfg, "[existing]\noption: value\n")
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
add_config_section(
|
||||||
|
"new_section",
|
||||||
|
[instance],
|
||||||
|
options=[("opt1", "val1"), ("opt2", ["line1", "line2"])],
|
||||||
|
)
|
||||||
|
|
||||||
|
text = cfg.read_text(encoding="utf-8")
|
||||||
|
assert "[new_section]" in text
|
||||||
|
assert "opt1: val1" in text
|
||||||
|
assert " line1" in text
|
||||||
|
assert " line2" in text
|
||||||
|
|
||||||
|
def test_skips_existing_section(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "printer.cfg"
|
||||||
|
_write_cfg(cfg, "[section]\noption: value\n")
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
add_config_section("section", [instance])
|
||||||
|
|
||||||
|
text = cfg.read_text(encoding="utf-8")
|
||||||
|
assert text.count("[section]") == 1
|
||||||
|
|
||||||
|
def test_warns_when_file_missing(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "missing.cfg"
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
add_config_section("section", [instance])
|
||||||
|
|
||||||
|
assert not cfg.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddConfigSectionAtTop:
|
||||||
|
def test_prepends_section(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "printer.cfg"
|
||||||
|
original = "[old]\noption: value\n"
|
||||||
|
_write_cfg(cfg, original)
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
add_config_section_at_top("top_section", [instance])
|
||||||
|
|
||||||
|
text = cfg.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert lines[0] == "[top_section]"
|
||||||
|
assert "[old]" in text
|
||||||
|
assert text.endswith("\n")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveConfigSection:
|
||||||
|
def test_removes_existing(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "printer.cfg"
|
||||||
|
_write_cfg(cfg, "[keep]\noption: 1\n[drop]\noption: 2\n")
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
removed = remove_config_section("drop", [instance])
|
||||||
|
|
||||||
|
assert removed == [instance]
|
||||||
|
text = cfg.read_text(encoding="utf-8")
|
||||||
|
assert "[drop]" not in text
|
||||||
|
assert "[keep]" in text
|
||||||
|
|
||||||
|
def test_skips_missing_section(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "printer.cfg"
|
||||||
|
_write_cfg(cfg, "[keep]\noption: 1\n")
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
removed = remove_config_section("missing", [instance])
|
||||||
|
|
||||||
|
assert removed == []
|
||||||
|
assert cfg.read_text(encoding="utf-8") == "[keep]\noption: 1\n"
|
||||||
|
|
||||||
|
def test_warns_when_file_missing(self, tmp_path: Path) -> None:
|
||||||
|
cfg = tmp_path / "missing.cfg"
|
||||||
|
instance = _FakeInstance(cfg)
|
||||||
|
|
||||||
|
removed = remove_config_section("section", [instance])
|
||||||
|
|
||||||
|
assert removed == []
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
from typing import Any, List
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from utils.fs_utils import (
|
||||||
|
check_file_exist,
|
||||||
|
create_folders,
|
||||||
|
create_symlink,
|
||||||
|
get_data_dir,
|
||||||
|
remove_file,
|
||||||
|
remove_with_sudo,
|
||||||
|
run_remove_routines,
|
||||||
|
unzip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFileExist:
|
||||||
|
def test_returns_true_for_existing_file(self, tmp_path: Path) -> None:
|
||||||
|
file = tmp_path / "file.txt"
|
||||||
|
file.write_text("x")
|
||||||
|
assert check_file_exist(file) is True
|
||||||
|
|
||||||
|
def test_returns_false_for_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
assert check_file_exist(tmp_path / "missing") is False
|
||||||
|
|
||||||
|
def test_returns_false_for_broken_symlink(self, tmp_path: Path) -> None:
|
||||||
|
link = tmp_path / "link"
|
||||||
|
link.symlink_to(tmp_path / "target")
|
||||||
|
assert check_file_exist(link) is False
|
||||||
|
|
||||||
|
def test_with_sudo_uses_subprocess(self, monkeypatch) -> None:
|
||||||
|
calls: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_check_output(cmd: List[str], **kwargs: Any) -> bytes:
|
||||||
|
calls.append(cmd)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.check_output", fake_check_output)
|
||||||
|
path = Path("/some/path")
|
||||||
|
assert check_file_exist(path, sudo=True) is True
|
||||||
|
assert calls[0] == ["sudo", "find", "-L", "/some/path", "-maxdepth", "0"]
|
||||||
|
|
||||||
|
def test_with_sudo_returns_false_on_error(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.fs_utils.check_output",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(CalledProcessError(1, "find")),
|
||||||
|
)
|
||||||
|
assert check_file_exist(Path("/some/path"), sudo=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSymlink:
|
||||||
|
def test_calls_ln_with_correct_args(self, monkeypatch) -> None:
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.run", fake_run)
|
||||||
|
create_symlink(Path("/src"), Path("/dst"))
|
||||||
|
assert runs == [["ln", "-sf", "/src", "/dst"]]
|
||||||
|
|
||||||
|
def test_uses_sudo(self, monkeypatch) -> None:
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.run", fake_run)
|
||||||
|
create_symlink(Path("/src"), Path("/dst"), sudo=True)
|
||||||
|
assert runs == [["sudo", "ln", "-sf", "/src", "/dst"]]
|
||||||
|
|
||||||
|
def test_raises_on_failure(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.fs_utils.run",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(CalledProcessError(1, "ln")),
|
||||||
|
)
|
||||||
|
with pytest.raises(CalledProcessError):
|
||||||
|
create_symlink(Path("/src"), Path("/dst"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveWithSudo:
|
||||||
|
def test_removes_existing_files(self, monkeypatch) -> None:
|
||||||
|
calls: List[tuple] = []
|
||||||
|
|
||||||
|
def fake_call(cmd: List[str], **kwargs: Any) -> int:
|
||||||
|
calls.append(("call", cmd))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
calls.append(("run", cmd))
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.call", fake_call)
|
||||||
|
monkeypatch.setattr("utils.fs_utils.run", fake_run)
|
||||||
|
|
||||||
|
result = remove_with_sudo(Path("/some/file"))
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert ("call", ["sudo", "find", "/some/file"]) in calls
|
||||||
|
assert ("run", ["sudo", "rm", "-rf", "/some/file"]) in calls
|
||||||
|
|
||||||
|
def test_skips_missing_files(self, monkeypatch) -> None:
|
||||||
|
def fake_call(cmd: List[str], **kwargs: Any) -> int:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.call", fake_call)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.fs_utils.run",
|
||||||
|
lambda *a, **k: pytest.fail("should not run rm for missing file"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert remove_with_sudo(Path("/some/file")) is False
|
||||||
|
|
||||||
|
def test_accepts_list(self, monkeypatch) -> None:
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_call(cmd: List[str], **kwargs: Any) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.call", fake_call)
|
||||||
|
monkeypatch.setattr("utils.fs_utils.run", fake_run)
|
||||||
|
|
||||||
|
remove_with_sudo([Path("/a"), Path("/b")])
|
||||||
|
|
||||||
|
assert runs == [
|
||||||
|
["sudo", "rm", "-rf", "/a"],
|
||||||
|
["sudo", "rm", "-rf", "/b"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveFile:
|
||||||
|
def test_calls_shell_rm(self, monkeypatch) -> None:
|
||||||
|
runs: List[Any] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: str, **kwargs: Any) -> Any:
|
||||||
|
runs.append((cmd, kwargs.get("shell")))
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.fs_utils.run", fake_run)
|
||||||
|
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
remove_file(Path("/some/file"), sudo=True)
|
||||||
|
|
||||||
|
assert runs == [("sudo rm -f /some/file", True)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunRemoveRoutines:
|
||||||
|
def test_returns_false_for_missing(self, tmp_path: Path) -> None:
|
||||||
|
assert run_remove_routines(tmp_path / "missing") is False
|
||||||
|
|
||||||
|
def test_removes_file(self, tmp_path: Path) -> None:
|
||||||
|
file = tmp_path / "file.txt"
|
||||||
|
file.write_text("x")
|
||||||
|
assert run_remove_routines(file) is True
|
||||||
|
assert not file.exists()
|
||||||
|
|
||||||
|
def test_removes_directory(self, tmp_path: Path) -> None:
|
||||||
|
directory = tmp_path / "dir"
|
||||||
|
directory.mkdir()
|
||||||
|
(directory / "child").write_text("x")
|
||||||
|
assert run_remove_routines(directory) is True
|
||||||
|
assert not directory.exists()
|
||||||
|
|
||||||
|
def test_removes_symlink(self, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "target"
|
||||||
|
target.write_text("x")
|
||||||
|
link = tmp_path / "link"
|
||||||
|
link.symlink_to(target)
|
||||||
|
assert run_remove_routines(link) is True
|
||||||
|
assert not link.exists()
|
||||||
|
assert target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnzip:
|
||||||
|
def test_extracts_contents(self, tmp_path: Path) -> None:
|
||||||
|
archive = tmp_path / "archive.zip"
|
||||||
|
target = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
with ZipFile(archive, "w") as zf:
|
||||||
|
zf.writestr("hello.txt", "world")
|
||||||
|
|
||||||
|
unzip(archive, target)
|
||||||
|
|
||||||
|
assert (target / "hello.txt").read_text() == "world"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateFolders:
|
||||||
|
def test_creates_missing_directories(self, tmp_path: Path) -> None:
|
||||||
|
dirs = [tmp_path / "a", tmp_path / "b"]
|
||||||
|
create_folders(dirs)
|
||||||
|
assert all(d.exists() for d in dirs)
|
||||||
|
|
||||||
|
def test_skips_existing(self, tmp_path: Path) -> None:
|
||||||
|
existing = tmp_path / "exists"
|
||||||
|
existing.mkdir()
|
||||||
|
create_folders([existing])
|
||||||
|
assert existing.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDataDir:
|
||||||
|
def test_reads_from_service_file(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
service = tmp_path / "klipper.service"
|
||||||
|
service.write_text(
|
||||||
|
"EnvironmentFile=/home/user/printer_data/systemd/klipper.env\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_service_path(instance_type: type, suffix: str) -> Path:
|
||||||
|
return service
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.sys_utils.get_service_file_path", fake_service_path)
|
||||||
|
monkeypatch.setattr("utils.fs_utils.Path.home", lambda: tmp_path / "home")
|
||||||
|
|
||||||
|
result = get_data_dir(object, "")
|
||||||
|
assert result == Path("/home/user/printer_data")
|
||||||
|
|
||||||
|
def test_falls_back_to_suffixed_data_dir(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
def fake_service_path(instance_type: type, suffix: str) -> Path:
|
||||||
|
return tmp_path / "no-such.service"
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.sys_utils.get_service_file_path", fake_service_path)
|
||||||
|
home = tmp_path / "home"
|
||||||
|
monkeypatch.setattr("utils.fs_utils.Path.home", lambda: home)
|
||||||
|
|
||||||
|
assert get_data_dir(object, "1") == home / "printer_1_data"
|
||||||
|
|
||||||
|
def test_falls_back_to_default_data_dir(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
def fake_service_path(instance_type: type, suffix: str) -> Path:
|
||||||
|
return tmp_path / "no-such.service"
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.sys_utils.get_service_file_path", fake_service_path)
|
||||||
|
home = tmp_path / "home"
|
||||||
|
monkeypatch.setattr("utils.fs_utils.Path.home", lambda: home)
|
||||||
|
|
||||||
|
assert get_data_dir(object, "") == home / "printer_data"
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from utils.git_utils import (
|
||||||
|
GitException,
|
||||||
|
compare_semver_tags,
|
||||||
|
get_current_branch,
|
||||||
|
get_latest_remote_tag,
|
||||||
|
get_latest_unstable_tag,
|
||||||
|
get_local_commit,
|
||||||
|
get_local_tags,
|
||||||
|
get_remote_commit,
|
||||||
|
get_remote_tags,
|
||||||
|
get_repo_name,
|
||||||
|
get_repo_url,
|
||||||
|
git_clone_wrapper,
|
||||||
|
git_cmd_checkout,
|
||||||
|
git_cmd_clone,
|
||||||
|
git_cmd_pull,
|
||||||
|
git_pull_wrapper,
|
||||||
|
rollback_repository,
|
||||||
|
)
|
||||||
|
from utils.instance_type import InstanceType
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitCmdPull:
|
||||||
|
def test_missing_dir_raises(self, tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
git_cmd_pull(missing)
|
||||||
|
|
||||||
|
def test_dir_without_git_raises(self, tmp_path: Path) -> None:
|
||||||
|
empty = tmp_path / "no-git"
|
||||||
|
empty.mkdir()
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
git_cmd_pull(empty)
|
||||||
|
|
||||||
|
def test_success_runs_git_pull(self, monkeypatch) -> None:
|
||||||
|
repo = Path("/fake/repo")
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.Path.exists", lambda self: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.Path.joinpath", lambda self, name: repo / name
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", fake_run)
|
||||||
|
|
||||||
|
git_cmd_pull(repo)
|
||||||
|
assert runs == [["git", "pull"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitCmdCheckout:
|
||||||
|
def test_missing_dir_raises(self, tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
git_cmd_checkout("main", missing)
|
||||||
|
|
||||||
|
def test_dir_without_git_raises(self, tmp_path: Path) -> None:
|
||||||
|
empty = tmp_path / "no-git"
|
||||||
|
empty.mkdir()
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
git_cmd_checkout("main", empty)
|
||||||
|
|
||||||
|
def test_none_branch_returns(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.run",
|
||||||
|
lambda *a, **k: pytest.fail("should not run checkout for None branch"),
|
||||||
|
)
|
||||||
|
git_cmd_checkout(None, Path("/repo"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitPullWrapper:
|
||||||
|
def test_missing_dir_does_not_raise(self, tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
git_pull_wrapper(missing)
|
||||||
|
|
||||||
|
def test_dir_without_git_does_not_raise(self, tmp_path: Path) -> None:
|
||||||
|
empty = tmp_path / "no-git"
|
||||||
|
empty.mkdir()
|
||||||
|
git_pull_wrapper(empty)
|
||||||
|
|
||||||
|
def test_success_calls_git_pull(self, monkeypatch) -> None:
|
||||||
|
repo = Path("/fake/repo")
|
||||||
|
called: List[Path] = []
|
||||||
|
|
||||||
|
def fake_git_cmd_pull(path: Path) -> None:
|
||||||
|
called.append(path)
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_pull", fake_git_cmd_pull)
|
||||||
|
git_pull_wrapper(repo)
|
||||||
|
assert called == [repo]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRollbackRepository:
|
||||||
|
def test_missing_dir_raises(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
called: list[bool] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_number_input",
|
||||||
|
lambda *_a, **_k: called.append(True) or 1,
|
||||||
|
)
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
rollback_repository(missing, InstanceType)
|
||||||
|
assert not called
|
||||||
|
|
||||||
|
def test_dir_without_git_raises(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
empty = tmp_path / "no-git"
|
||||||
|
empty.mkdir()
|
||||||
|
called: list[bool] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_number_input",
|
||||||
|
lambda *_a, **_k: called.append(True) or 1,
|
||||||
|
)
|
||||||
|
with pytest.raises(GitException):
|
||||||
|
rollback_repository(empty, InstanceType)
|
||||||
|
assert not called
|
||||||
|
|
||||||
|
def test_aborts_when_not_confirmed(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_number_input", lambda *a, **k: 2)
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_confirm", lambda *a, **k: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_instances", lambda *a, **k: ["instance"]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.InstanceManager.stop_all",
|
||||||
|
lambda *a, **k: pytest.fail("should not stop when aborted"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.run",
|
||||||
|
lambda *a, **k: pytest.fail("should not reset when aborted"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.InstanceManager.start_all",
|
||||||
|
lambda *a, **k: pytest.fail("should not start when aborted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
rollback_repository(repo, InstanceType)
|
||||||
|
|
||||||
|
def test_resets_and_restarts_when_confirmed(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
stops: List[List[Any]] = []
|
||||||
|
starts: List[List[Any]] = []
|
||||||
|
resets: List[List[str]] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_number_input", lambda *a, **k: 3)
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_confirm", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_instances", lambda *a, **k: ["instance"]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.InstanceManager.stop_all",
|
||||||
|
lambda instances: stops.append(instances),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.InstanceManager.start_all",
|
||||||
|
lambda instances: starts.append(instances),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
resets.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", fake_run)
|
||||||
|
|
||||||
|
rollback_repository(repo, InstanceType)
|
||||||
|
|
||||||
|
assert stops == [["instance"]]
|
||||||
|
assert resets == [["git", "reset", "--hard", "HEAD~3"]]
|
||||||
|
assert starts == [["instance"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRepoName:
|
||||||
|
def test_extracts_org_and_repo(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.check_output",
|
||||||
|
lambda *a, **k: b"https://github.com/dw-0/kiauh.git\n",
|
||||||
|
)
|
||||||
|
assert get_repo_name(repo) == ("dw-0", "kiauh")
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_repo_name(tmp_path / "missing") == (None, None)
|
||||||
|
|
||||||
|
def test_returns_none_on_git_error(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.check_output",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(CalledProcessError(1, "git")),
|
||||||
|
)
|
||||||
|
assert get_repo_name(repo) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentBranch:
|
||||||
|
def test_returns_branch(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.check_output", lambda *a, **k: b"feature-x\n"
|
||||||
|
)
|
||||||
|
assert get_current_branch(repo) == "feature-x"
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_current_branch(tmp_path / "missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLocalTags:
|
||||||
|
def test_sorts_semver(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.check_output",
|
||||||
|
lambda *a, **k: b"v1.0.0\nv1.0.1\nv1.0.10\nv1.0.2\nv2.0.0-beta.1\n",
|
||||||
|
)
|
||||||
|
assert get_local_tags(repo) == [
|
||||||
|
"v1.0.0",
|
||||||
|
"v1.0.1",
|
||||||
|
"v1.0.2",
|
||||||
|
"v1.0.10",
|
||||||
|
"v2.0.0-beta.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_returns_empty_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_local_tags(tmp_path / "missing") == []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, code: int, body: bytes = b""):
|
||||||
|
self._code = code
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getcode(self) -> int:
|
||||||
|
return self._code
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRemoteTags:
|
||||||
|
def test_parses_github_api(self, monkeypatch) -> None:
|
||||||
|
body = b'[{"name":"v1.0.0"},{"name":"v1.1.0"}]'
|
||||||
|
|
||||||
|
class FakeUrlLib:
|
||||||
|
@staticmethod
|
||||||
|
def urlopen(url: str):
|
||||||
|
return _FakeResponse(200, body)
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.urllib.request", FakeUrlLib())
|
||||||
|
assert get_remote_tags("dw-0/kiauh") == ["v1.0.0", "v1.1.0"]
|
||||||
|
|
||||||
|
def test_returns_empty_on_http_error(self, monkeypatch) -> None:
|
||||||
|
class FakeUrlLib:
|
||||||
|
@staticmethod
|
||||||
|
def urlopen(url: str):
|
||||||
|
return _FakeResponse(404)
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.urllib.request", FakeUrlLib())
|
||||||
|
assert get_remote_tags("dw-0/kiauh") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLatestRemoteTag:
|
||||||
|
def test_returns_first_tag(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_remote_tags", lambda *_a, **_k: ["v2.0.0", "v1.0.0"]
|
||||||
|
)
|
||||||
|
assert get_latest_remote_tag("dw-0/kiauh") == "v2.0.0"
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_tags(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_remote_tags", lambda *_a, **_k: [])
|
||||||
|
assert get_latest_remote_tag("dw-0/kiauh") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLatestUnstableTag:
|
||||||
|
def test_filters_prereleases(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_remote_tags",
|
||||||
|
lambda *_a, **_k: ["v2.0.0", "v2.0.0-rc.1", "v1.0.0-beta.2"],
|
||||||
|
)
|
||||||
|
assert get_latest_unstable_tag("dw-0/kiauh") == "v2.0.0-rc.1"
|
||||||
|
|
||||||
|
def test_returns_empty_when_stable_only(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_remote_tags", lambda *_a, **_k: ["v2.0.0", "v1.0.0"]
|
||||||
|
)
|
||||||
|
assert get_latest_unstable_tag("dw-0/kiauh") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompareSemverTags:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tag1,tag2,expected",
|
||||||
|
[
|
||||||
|
("v1.0.0", "v1.0.1", False),
|
||||||
|
("v1.1.0", "v1.0.1", True),
|
||||||
|
("v1.0.0", "v1.0.0", False),
|
||||||
|
("v2.0.0", "v1.9.9", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comparison(self, tag1: str, tag2: str, expected: bool) -> None:
|
||||||
|
assert compare_semver_tags(tag1, tag2) is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLocalCommit:
|
||||||
|
def test_describes_head(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.check_output",
|
||||||
|
lambda *a, **k: "v1.0.0-0-gabc1234",
|
||||||
|
)
|
||||||
|
assert get_local_commit(repo) == "v1.0.0-0-gabc1234"
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_local_commit(tmp_path / "missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRemoteCommit:
|
||||||
|
def test_describes_origin(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
def fake_check_output(cmd: str, **kwargs: Any) -> str:
|
||||||
|
if "HEAD" in cmd:
|
||||||
|
return "v1.0.0"
|
||||||
|
return "origin/main"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_current_branch", lambda *_a, **_k: "main"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.git_utils.check_output", fake_check_output)
|
||||||
|
assert get_remote_commit(repo) == "origin/main"
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_remote_commit(tmp_path / "missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitCmdClone:
|
||||||
|
def test_without_blobless(self, monkeypatch) -> None:
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", fake_run)
|
||||||
|
git_cmd_clone("https://github.com/dw-0/kiauh", Path("/target"))
|
||||||
|
assert runs == [["git", "clone", "https://github.com/dw-0/kiauh", "/target"]]
|
||||||
|
|
||||||
|
def test_with_blobless(self, monkeypatch) -> None:
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", fake_run)
|
||||||
|
git_cmd_clone(
|
||||||
|
"https://github.com/dw-0/kiauh", Path("/target"), blobless=True
|
||||||
|
)
|
||||||
|
assert runs == [
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--filter=blob:none",
|
||||||
|
"https://github.com/dw-0/kiauh",
|
||||||
|
"/target",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitCmdCheckoutSingle:
|
||||||
|
def test_runs_git_checkout(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
runs: List[List[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: List[str], **kwargs: Any) -> Any:
|
||||||
|
runs.append(cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", fake_run)
|
||||||
|
git_cmd_checkout("dev", repo)
|
||||||
|
assert runs == [["git", "checkout", "dev"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRepoUrl:
|
||||||
|
def test_extracts_remote_url(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
stdout = "https://github.com/dw-0/kiauh.git\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.run", lambda *a, **k: FakeResult())
|
||||||
|
assert get_repo_url(repo) == "https://github.com/dw-0/kiauh.git"
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_repo(self, tmp_path: Path) -> None:
|
||||||
|
assert get_repo_url(tmp_path / "missing") is None
|
||||||
|
|
||||||
|
def test_returns_none_on_git_error(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
(repo / ".git").mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.run",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(CalledProcessError(1, "git")),
|
||||||
|
)
|
||||||
|
assert get_repo_url(repo) is None
|
||||||
|
|
||||||
|
|
||||||
|
class _CloneRecorder:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls: List[tuple] = []
|
||||||
|
self.checkouts: List[tuple] = []
|
||||||
|
self.removed: List[Path] = []
|
||||||
|
|
||||||
|
def fake_clone(self, repo: str, target: Path, blobless: bool = False) -> None:
|
||||||
|
self.calls.append((repo, target, blobless))
|
||||||
|
|
||||||
|
def fake_checkout(self, branch: str | None, target: Path) -> None:
|
||||||
|
self.checkouts.append((branch, target))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitCloneWrapper:
|
||||||
|
def test_clones_when_target_missing(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "kiauh"
|
||||||
|
recorder = _CloneRecorder()
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_clone", recorder.fake_clone)
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_checkout", recorder.fake_checkout)
|
||||||
|
|
||||||
|
git_clone_wrapper("https://github.com/dw-0/kiauh", target, branch="dev")
|
||||||
|
|
||||||
|
assert recorder.calls == [("https://github.com/dw-0/kiauh", target, True)]
|
||||||
|
assert recorder.checkouts == [("dev", target)]
|
||||||
|
|
||||||
|
def test_skips_checkout_for_main(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "kiauh"
|
||||||
|
recorder = _CloneRecorder()
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_clone", recorder.fake_clone)
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_checkout", recorder.fake_checkout)
|
||||||
|
|
||||||
|
git_clone_wrapper("https://github.com/dw-0/kiauh", target, branch="main")
|
||||||
|
|
||||||
|
assert recorder.checkouts == []
|
||||||
|
|
||||||
|
def test_prompts_before_overwrite(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "kiauh"
|
||||||
|
target.mkdir()
|
||||||
|
recorder = _CloneRecorder()
|
||||||
|
removed: List[Path] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_clone", recorder.fake_clone)
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_checkout", recorder.fake_checkout)
|
||||||
|
monkeypatch.setattr("utils.git_utils.shutil.rmtree", lambda p: removed.append(p))
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_confirm", lambda *a, **k: True)
|
||||||
|
|
||||||
|
git_clone_wrapper("https://github.com/dw-0/kiauh", target, branch="dev")
|
||||||
|
|
||||||
|
assert removed == [target]
|
||||||
|
assert recorder.calls == [("https://github.com/dw-0/kiauh", target, True)]
|
||||||
|
|
||||||
|
def test_respects_decline_to_overwrite(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "kiauh"
|
||||||
|
target.mkdir()
|
||||||
|
recorder = _CloneRecorder()
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_clone", recorder.fake_clone)
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_checkout", recorder.fake_checkout)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.shutil.rmtree",
|
||||||
|
lambda *a, **k: pytest.fail("should not remove"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("utils.git_utils.get_confirm", lambda *a, **k: False)
|
||||||
|
|
||||||
|
git_clone_wrapper("https://github.com/dw-0/kiauh", target)
|
||||||
|
|
||||||
|
assert recorder.calls == []
|
||||||
|
|
||||||
|
def test_force_overwrites_without_prompt(self, monkeypatch, tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "kiauh"
|
||||||
|
target.mkdir()
|
||||||
|
recorder = _CloneRecorder()
|
||||||
|
removed: List[Path] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_clone", recorder.fake_clone)
|
||||||
|
monkeypatch.setattr("utils.git_utils.git_cmd_checkout", recorder.fake_checkout)
|
||||||
|
monkeypatch.setattr("utils.git_utils.shutil.rmtree", lambda p: removed.append(p))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"utils.git_utils.get_confirm",
|
||||||
|
lambda *a, **k: pytest.fail("should not prompt when forced"),
|
||||||
|
)
|
||||||
|
|
||||||
|
git_clone_wrapper("https://github.com/dw-0/kiauh", target, force=True)
|
||||||
|
|
||||||
|
assert removed == [target]
|
||||||
|
assert recorder.calls == [("https://github.com/dw-0/kiauh", target, True)]
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from utils.input_utils import (
|
||||||
|
format_question,
|
||||||
|
get_confirm,
|
||||||
|
get_number_input,
|
||||||
|
get_selection_input,
|
||||||
|
get_string_input,
|
||||||
|
validate_number_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _input_sequence(answers: List[str]):
|
||||||
|
it = iter(answers)
|
||||||
|
|
||||||
|
def _input(_prompt: str = "") -> str:
|
||||||
|
return next(it)
|
||||||
|
|
||||||
|
return _input
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConfirm:
|
||||||
|
def test_accepts_yes(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["y"]))
|
||||||
|
assert get_confirm("go?") is True
|
||||||
|
|
||||||
|
def test_accepts_no(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["n"]))
|
||||||
|
assert get_confirm("go?") is False
|
||||||
|
|
||||||
|
def test_default_yes_on_empty(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence([""]))
|
||||||
|
assert get_confirm("go?", default_choice=True) is True
|
||||||
|
|
||||||
|
def test_default_no_on_empty(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence([""]))
|
||||||
|
assert get_confirm("go?", default_choice=False) is False
|
||||||
|
|
||||||
|
def test_handles_invalid_then_valid(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["maybe", "yes"]))
|
||||||
|
assert get_confirm("go?") is True
|
||||||
|
|
||||||
|
def test_go_back_returns_none(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["b"]))
|
||||||
|
assert get_confirm("go?", allow_go_back=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNumberInput:
|
||||||
|
def test_returns_valid(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["5"]))
|
||||||
|
assert get_number_input("count?", 1, 10) == 5
|
||||||
|
|
||||||
|
def test_uses_default(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence([""]))
|
||||||
|
assert get_number_input("count?", 1, default=3) == 3
|
||||||
|
|
||||||
|
def test_enforces_minimum(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["0", "2"]))
|
||||||
|
assert get_number_input("count?", 1) == 2
|
||||||
|
|
||||||
|
def test_enforces_maximum(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["11", "9"]))
|
||||||
|
assert get_number_input("count?", 1, 10) == 9
|
||||||
|
|
||||||
|
def test_go_back_returns_none(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["b"]))
|
||||||
|
assert get_number_input("count?", 1, allow_go_back=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStringInput:
|
||||||
|
def test_accepts_alphanumeric(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["abc123"]))
|
||||||
|
assert get_string_input("name?") == "abc123"
|
||||||
|
|
||||||
|
def test_rejects_empty(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["", "value"]))
|
||||||
|
assert get_string_input("name?") == "value"
|
||||||
|
|
||||||
|
def test_uses_default(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence([""]))
|
||||||
|
assert get_string_input("name?", default="fallback") == "fallback"
|
||||||
|
|
||||||
|
def test_validates_regex(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["@", "#"]))
|
||||||
|
assert get_string_input("name?", regex=r"^#+$") == "#"
|
||||||
|
|
||||||
|
def test_rejects_excluded(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["taken", "free"]))
|
||||||
|
assert get_string_input("name?", exclude=["taken"]) == "free"
|
||||||
|
|
||||||
|
def test_allows_special_chars(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["a-b_c"]))
|
||||||
|
assert get_string_input("name?", allow_special_chars=True) == "a-b_c"
|
||||||
|
|
||||||
|
def test_allows_empty_with_special_chars(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence([""]))
|
||||||
|
assert (
|
||||||
|
get_string_input("name?", allow_empty=True, allow_special_chars=True) == ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSelectionInput:
|
||||||
|
def test_from_list(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["b"]))
|
||||||
|
assert get_selection_input("pick?", ["a", "b", "c"]) == "b"
|
||||||
|
|
||||||
|
def test_from_dict(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["two"]))
|
||||||
|
assert get_selection_input("pick?", {"one": 1, "two": 2}) == "two"
|
||||||
|
|
||||||
|
def test_invalid_then_valid(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["z", "a"]))
|
||||||
|
assert get_selection_input("pick?", ["a", "b"]) == "a"
|
||||||
|
|
||||||
|
def test_invalid_type_raises(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("builtins.input", _input_sequence(["x"]))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
get_selection_input("pick?", 123) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatQuestion:
|
||||||
|
def test_includes_default(self) -> None:
|
||||||
|
assert "default=5" in format_question("count", 5)
|
||||||
|
|
||||||
|
def test_no_default(self) -> None:
|
||||||
|
assert "count" in format_question("count")
|
||||||
|
assert "default" not in format_question("count")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateNumberInput:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,min_count,max_count,expected",
|
||||||
|
[
|
||||||
|
("5", 1, 10, 5),
|
||||||
|
("1", 1, 10, 1),
|
||||||
|
("10", 1, 10, 10),
|
||||||
|
("3", 1, None, 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid(
|
||||||
|
self, value: str, min_count: int, max_count: Any, expected: int
|
||||||
|
) -> None:
|
||||||
|
assert validate_number_input(value, min_count, max_count) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,min_count,max_count",
|
||||||
|
[
|
||||||
|
("0", 1, 10),
|
||||||
|
("11", 1, 10),
|
||||||
|
("-1", 0, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_raises(self, value: str, min_count: int, max_count: Any) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_number_input(value, min_count, max_count)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user