Compare commits

...

17 Commits

Author SHA1 Message Date
dw-0
1f08537bcf docs: add Raspberry Pi setup guide and update installation instructions
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-29 20:28:16 +02:00
dw-0
393822b8b6 feat(docs): add MkDocs documentation setup with Docker support and restructure markdown files
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-29 13:39:23 +02:00
dw-0
e590f668e6 fix(common): return default version if no tags exist in get_kiauh_version
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 18:28:40 +02:00
Tovi
075f2d384b docs(readme): add chinese readme instructions (#707)
* add chinese readme

* translated raspberry pi to chinese
2025-08-28 17:45:39 +02:00
dw-0
afdde34721 feat(core): add repository management to settings (#718)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 16:22:02 +02:00
dw-0
393dd1d5bf feat(extension): add OctoPrint installer (#716)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 16:21:42 +02:00
dw-0
8170057434 fix(moonraker): correctly patch trusted_clients options
fixes #711 #709

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-03 10:07:13 +02:00
Maksym Pyrozhok
985b66d41f chore: fix typos (#695)
Fix typo.
2025-07-12 19:36:38 +02:00
dw-0
f95d2586bf fix(webclient): add config.json to moonraker persistent files (#699)
fixes #694

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-06-28 10:12:28 +02:00
dw-0
f5141e7eff fix(mainsail): check for json configured as instanceDB (#698)
fix(mainsail): check for json configures as instanceDB

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-06-27 22:37:44 +02:00
dw-0
33113e72e9 fix: exception raised on pip warning (#688)
pip seems to write to stderr on warnings, caused by retries. even if the process exits with 0.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-05-31 17:44:02 +02:00
dw-0
6f59fd06aa fix: do not upgrade pip before installing packages (#680)
pip 25 seems to introduce some compatibility issues.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-05-02 20:08:32 +02:00
dw-0
56ea43ccb6 refactor: improve typesafety KiauhSettings
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:19:38 +02:00
dw-0
25e22c993f chore(scp): update SimpleConfigParser
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:15:12 +02:00
dw-0
ead521b377 refactor: replace mypy with pyright
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:07:56 +02:00
dw-0
3c952ccc12 refactor: use sane fallbacks on missing kiauh config options
for some options a warning is print if the fallback is used

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-12 15:12:11 +02:00
dw-0
c8f713c00e fix: no validation of optional_speedups option
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-12 00:36:34 +02:00
45 changed files with 1249 additions and 225 deletions

View File

@@ -11,5 +11,5 @@ end_of_line = lf
[*.py] [*.py]
max_line_length = 88 max_line_length = 88
[*.{sh,yml,yaml,json}] [*.{sh,yml,yaml,json,md}]
indent_size = 2 indent_size = 2

31
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Deploy Documentation
on:
workflow_dispatch:
push:
branches:
- docs
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build and deploy documentation
run: mkdocs gh-deploy --force

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM squidfunk/mkdocs-material:latest
# Install additional plugins required by our mkdocs configuration
RUN pip install \
mkdocs-git-revision-date-localized-plugin \
mkdocstrings[python]

206
README_zh.md Normal file
View File

@@ -0,0 +1,206 @@
# KIAUH - Klipper 安装与更新助手
<p align="center">
<a>
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/kiauh.png" alt="KIAUH logo" height="181">
<h1 align="center">Klipper Installation And Update Helper</h1>
</a>
</p>
<p align="center">
一个方便的安装脚本让安装Klipper以及更多组件变得轻松
</p>
<p align="center">
<a><img src="https://img.shields.io/github/license/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/stars/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/forks/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/languages/top/dw-0/kiauh?logo=gnubash&logoColor=white"></a>
<a><img src="https://img.shields.io/github/v/tag/dw-0/kiauh"></a>
<br />
<a><img src="https://img.shields.io/github/last-commit/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/contributors/dw-0/kiauh"></a>
</p>
## 📄 使用说明
### 📋 系统要求
KIAUH 是一个帮助您在 Linux 系统上安装 Klipper 的脚本工具,
它需要一个已经写入树莓派或其他单板计算机SD 卡的 Linux 系统。
如果您使用树莓派,推荐使用 `Raspberry Pi OS Lite (32位或64位)` 系统镜像。
[官方 Raspberry Pi Imager](https://www.raspberrypi.com/software/) 是将此类镜像写入 SD 卡的最简单方式。
* 下载、安装并启动 Raspberry Pi Imager 后,
选择 `Choose OS -> Raspberry Pi OS (other)`:
<p align="center">
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager1.png" alt="KIAUH logo" height="350">
</p>
* 然后选择 `Raspberry Pi OS Lite (32位)` (或如果您想使用64位版本):
<p align="center">
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager2.png" alt="KIAUH logo" height="350">
</p>
* 返回 Raspberry Pi Imager 主界面,选择对应的 SD 卡作为写入目标。
* 确保点击左下角的齿轮图标(在主菜单中)
启用 SSH 并配置 Wi-Fi。
* 如果您需要更多关于使用 Raspberry Pi Imager 的帮助,请访问 [官方文档](https://www.raspberrypi.com/documentation/computers/getting-started.html)。
这些步骤**仅适用于**您实际使用树莓派的情况。如果您想使用其他单板计算机(如香橙派或其他 Pi 衍生产品),
请查找如何将合适的 Linux 镜像写入 SD 卡(通常使用 Balena Etcher
同时确保 KIAUH 能够在您要安装的 Linux 发行版上运行。
您在使用基于 Debian 11 Bullseye 的系统时可能会获得最佳体验。
请阅读本文档下方的注意事项。
### 💾 下载并使用 KIAUH
**📢 免责声明:使用此脚本的风险由您自行承担!**
* **第一步:**
要下载此脚本,需要先安装 git。
如果您不确定是否已安装 git请运行以下命令
```shell
sudo apt-get update && sudo apt-get install git -y
```
* **第二步:**
安装完 git 后,
使用以下命令将 KIAUH 下载到您的主目录:
```shell
cd ~ && git clone https://github.com/dw-0/kiauh.git
```
* **第三步:**
最后,通过运行以下命令启动 KIAUH
```shell
./kiauh/kiauh.sh
```
* **第四步:**
您现在应该会看到 KIAUH 的主菜单。
根据您的选择,
您会看到几个可选操作。
要选择某个操作,只需在 "Perform action" 提示后输入对应的数字并按回车键确认。
## ❗ 注意事项
### **📋 请查看 [更新日志](docs/changelog.md) 以了解可能的重要更新!**
- 主要在 Raspberry Pi OS Lite (Debian 10 Buster / Debian 11 Bullseye) 上测试
- 其他基于 Debian 的发行版(如 Ubuntu 20 到 22也可能正常工作
- 据报告在 Armbian 上也可用,但未进行详细测试
- 在使用此脚本的过程中,
您会被要求输入 sudo 密码。
因为有几个功能需要 sudo 权限。
## 🌐 相关资源与更多信息
<table align="center">
<tr>
<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/mainsail-crew/mainsail">Mainsail</a></h3></th>
</tr>
<tr>
<th><img src="https://raw.githubusercontent.com/Klipper3d/klipper/master/docs/img/klipper-logo.png" alt="Klipper Logo" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/9563098?v=4" alt="Arksine avatar" height="64"></th>
<th><img src="https://raw.githubusercontent.com/mainsail-crew/docs/master/assets/img/logo.png" alt="Mainsail Logo" height="64"></th>
</tr>
<tr>
<th>由 <a href="https://github.com/KevinOConnor">KevinOConnor</a></th>
<th>由 <a href="https://github.com/Arksine">Arksine</a></th>
<th>由 <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
</tr>
<tr>
<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/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
</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://avatars.githubusercontent.com/u/31575189?v=4" alt="jordanruthe avatar" height="64"></th>
<th><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></th>
</tr>
<tr>
<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/OctoPrint">OctoPrint</a></th>
</tr>
<tr>
<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/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
</tr>
<tr>
<th><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></th>
</tr>
<tr>
<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/TheSpaghettiDetective">Obico</a></th>
</tr>
<tr>
<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://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
</tr>
<tr>
<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://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
</tr>
<tr>
<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/crysxd">Christian Würthner</a></th>
</tr>
<tr>
<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>
</tr>
<tr>
<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>
</tr>
<tr>
<th>由 <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
<th>由 <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
</tr>
</table>
## 🎖️ 贡献者
<div align="center">
<a href="https://github.com/dw-0/kiauh/graphs/contributors">
<img src="https://contrib.rocks/image?repo=dw-0/kiauh" alt=""/>
</a>
</div>
<div align="center">
<img src="https://repobeats.axiom.co/api/embed/a1afbda9190c04a90cf4bd3061e5573bc836cb05.svg" alt="Repobeats analytics image"/>
</div>
## ✨ 特别感谢
* 非常感谢 [lixxbox](https://github.com/lixxbox) 设计了如此出色的 KIAUH 标志!
* 同时,非常感谢所有通过 [Ko-fi](https://ko-fi.com/dw__0) 支持我的工作的人!
* 最后但同样重要的是:感谢所有为 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>

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
mkdocs:
build: .
ports:
- "8000:8000"
volumes:
- ./:/docs
command: serve --dev-addr=0.0.0.0:8000

BIN
docs/assets/logo-large.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/assets/rpi_imager1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/assets/rpi_imager2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -276,7 +276,7 @@ Each service gets its corresponding instance added to the service filename.
* The user can now choose to install Klipper as a systemd service. * The user can now choose to install Klipper as a systemd service.
* The Shell Command extension and `shell_command.py` got renamed to G-Code Shell Command extension and `gcode_shell_command.py`. In case the [pending PR](https://github.com/KevinOConnor/klipper/pull/2173) will be merged in the future, this was an early attempt to dodge possible incompatibilities. The [G-Code Shell Command docs](gcode_shell_command.md) has been updated accordingly. * The Shell Command extension and `shell_command.py` got renamed to G-Code Shell Command extension and `gcode_shell_command.py`. In case the [pending PR](https://github.com/KevinOConnor/klipper/pull/2173) will be merged in the future, this was an early attempt to dodge possible incompatibilities. The [G-Code Shell Command docs](extensions/gcode-shell-command) has been updated accordingly.
* The way how KIAUH interacts and writes to the users printer.cfg got changed. Usually KIAUH wrote everything directly into the printer.cfg. The way it will work from now on is, that a new file called `kiauh.cfg` will be created if there is something that needs to be written to the printer.cfg and everything gets written to `kiauh.cfg` instead. The only thing which then gets written to the users printer.cfg is `[include kiauh.cfg]`. This line will be located at the very top of the existing printer.cfg with a little comment as a note. The user can then decide to either keep the `kiauh.cfg` or take its content, places it into the printer.cfg directly and remove the `[include kiauh.cfg]`. * The way how KIAUH interacts and writes to the users printer.cfg got changed. Usually KIAUH wrote everything directly into the printer.cfg. The way it will work from now on is, that a new file called `kiauh.cfg` will be created if there is something that needs to be written to the printer.cfg and everything gets written to `kiauh.cfg` instead. The only thing which then gets written to the users printer.cfg is `[include kiauh.cfg]`. This line will be located at the very top of the existing printer.cfg with a little comment as a note. The user can then decide to either keep the `kiauh.cfg` or take its content, places it into the printer.cfg directly and remove the `[include kiauh.cfg]`.

1
docs/extensions/index.md Normal file
View File

@@ -0,0 +1 @@
# Community Extensions

23
docs/index.md Normal file
View File

@@ -0,0 +1,23 @@
!!! tip "Important"
This documentation is for KIAUH version 6 and still work in progress!
<h1 align="center">
KIAUH - Klipper Installation And Update Helper
</h1>
<p align="center">
<img src="assets/logo-large.png" alt="KIAUH logo" width="400"/>
</p>
<p align="center" style="font-size: 1.2em; font-weight: bold;">
A handy installation script that makes installing Klipper (and more) a breeze!
</p>
## Features
- Easy installation of Klipper and related components
- Support for multiple instances
- Extension system for additional functionality
- Configuration management
- And more!

View File

@@ -0,0 +1,39 @@
# Installing KIAUH
In the following sections, you will be guided through the installation
process step-by-step.
To use KIAUH, it is enough to download the script and run it on your
Raspberry Pi or other compatible device. If you need to know how to
set up a Raspberry Pi or if you are unsure whether your current setup
is sufficient, please refer to the [Raspberry Pi Installation Guide](raspberry-pi-setup.md)
and follow the steps therein. Afterwards, you can return to this guide to install KIAUH.
### Prerequisites
Before you can download and run KIAUH, you need to ensure that ``git`` is
installed on your system. Open a terminal and run the following command:
```bash
sudo apt-get update && sudo apt-get install git -y
```
### Downloading KIAUH
After `git` was successfully installed, you can download KIAUH by
cloning the repository from GitHub. It is recommended to clone it into
your home directory. Run the following command in your terminal:
```bash
cd ~ && git clone https://github.com/dw-0/kiauh.git
```
### Running KIAUH
Once the repository is cloned, you can start KIAUH. Make sure you are in
your home directory and execute the script by running the following
command:
```bash
./kiauh/kiauh.sh
```
After executing the command, you will be presented with the KIAUH menu,
which allows you to install and manage various 3D printing software.
For more information on how to use KIAUH, please refer to the
[Usage Guide](usage.md).

View File

@@ -0,0 +1,49 @@
# Raspberry Pi Setup
This guide will help you set up a Raspberry Pi for running Klipper and other,
Klipper related 3D printing software. In case you are using a different single-board
computer (SBC), please refer to the manufacturer's instructions for installing
a compatible version of Linux on your device.
It is assumed that you have at least a Raspberry Pi 3 or newer, along with a
microSD card (at least 8GB, preferably 16GB or more) and a power supply.
Additionally, you will need a computer with an SD card reader to prepare
the microSD card.
KIAUH requires a Linux operating system that has already been flashed to your
Raspberry Pi's (or other SBC's) microSD card. As a result, you must ensure that you
already have a functional Linux system on hand before you can proceed with
installing KIAUH. `Raspberry Pi OS Lite` (either 32bit or 64bit) is a recommended Linux image
if you are using a Raspberry Pi.
---
To flash `Raspberry Pi OS Lite` to your microSD card using the official [Raspberry Pi Imager](https://www.raspberrypi.com/software/),
follow the steps below. If you encounter any issues or need further assistance, please refer to the [official Raspberry Pi documentation](https://www.raspberrypi.com/documentation/computers/getting-started.html).
1. Open the Raspberry Pi Imager application on your computer.
2. Click on `Choose OS` and select `Raspberry Pi OS (other)`.
![OS selection](https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager1.png)
3. Choose `Raspberry Pi OS Lite (32bit)` (or 64bit if desired).
![Lite selection](https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager2.png)
4. Insert the microSD card into your computer's SD card reader.
5. In the main menu of the Imager, select the correct microSD card.
6. Click the gear icon at the bottom left of the main menu to open advanced options.
7. Enable SSH and enter your Wi-Fi credentials.
!!! info
Wi-Fi is only necessary if you want to connect to your Raspberry Pi over a wireless network. If you plan to use a wired Ethernet connection, you can skip this step. SSH is required for remote access to your Raspberry Pi, so make sure to enable it.
8. Click `Save` to close the advanced options menu.
9. Click `Write` to start flashing the image to the microSD card.
!!! warning
All data on the microSD card will be overwritten!
10. Once the flashing process is complete, safely eject the microSD card from your computer.
11. Insert the microSD card into your Raspberry Pi.
12. Connect your Raspberry Pi to a power source to boot it up.
13. Wait for a few minutes to allow the Raspberry Pi to complete its initial setup.
14. You can now connect to your Raspberry Pi via SSH using the IP address assigned by your router. The default username is `pi` and the default password is `raspberry`.
If you successfully connected to your Raspberry Pi via SSH, you can proceed to install KIAUH by following the instructions in the [Installation Guide](installation.md).

View File

@@ -126,7 +126,7 @@ def create_example_moonraker_conf(
scp.read_file(target) scp.read_file(target)
trusted_clients: List[str] = [ trusted_clients: List[str] = [
f" {'.'.join(ip)}\n", f" {'.'.join(ip)}\n",
*scp.getval("authorization", "trusted_clients"), *scp.getvals("authorization", "trusted_clients"),
] ]
scp.set_option("server", "port", str(port)) scp.set_option("server", "port", str(port))

View File

@@ -102,6 +102,7 @@ def install_client(
section=f"update_manager {client.name}", section=f"update_manager {client.name}",
instances=mr_instances, instances=mr_instances,
options=[ options=[
("persistent_files", ["config.json"]),
("type", "web"), ("type", "web"),
("channel", "stable"), ("channel", "stable"),
("repo", str(client.repo_path)), ("repo", str(client.repo_path)),

View File

@@ -118,8 +118,8 @@ def enable_mainsail_remotemode() -> None:
c_json = MainsailData().client_dir.joinpath("config.json") c_json = MainsailData().client_dir.joinpath("config.json")
with open(c_json, "r") as f: with open(c_json, "r") as f:
config_data = json.load(f) config_data = json.load(f)
if config_data["instancesDB"] == "browser": if config_data["instancesDB"] == "browser" or config_data["instancesDB"] == "json":
Logger.print_info("Remote mode already configured. Skipped ...") Logger.print_info("Remote mode already configured. Skipped ...")
return return

View File

@@ -10,14 +10,17 @@ from __future__ import annotations
from typing import List, Literal, Type from typing import List, Literal, Type
from core.logger import Logger from core.logger import Logger, DialogType
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
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class RepoSelectMenu(BaseMenu): class RepoSelectMenu(BaseMenu):
def __init__( def __init__(
self, self,
@@ -48,26 +51,27 @@ class RepoSelectMenu(BaseMenu):
def set_options(self) -> None: def set_options(self) -> None:
self.options = {} self.options = {}
if self.repos:
if not self.repos: for idx, repo in enumerate(self.repos, start=1):
return self.options[str(idx)] = Option(
method=self.select_repository, opt_data=repo
for idx, repo in enumerate(self.repos, start=1): )
self.options[str(idx)] = Option( self.options["a"] = Option(method=self.add_repository)
method=self.select_repository, opt_data=repo self.options["r"] = Option(method=self.remove_repository)
) self.options["b"] = Option(method=self.go_back)
def print_menu(self) -> None: def print_menu(self) -> None:
menu = "╟───────────────────────────────────────────────────────╢\n" menu = "╟───────────────────────────────────────────────────────╢\n"
menu += "║ Available Repositories: ║\n" menu += "║ Available Repositories: ║\n"
menu += "╟───────────────────────────────────────────────────────╢\n" menu += "╟───────────────────────────────────────────────────────╢\n"
for idx, repo in enumerate(self.repos, start=1): for idx, repo in enumerate(self.repos, start=1):
url = f"● Repo: {repo.url.replace('.git', '')}" url = f"● Repo: {repo.url.replace('.git', '')}"
branch = f"└► Branch: {repo.branch}" branch = f"└► Branch: {repo.branch}"
menu += f"{idx}) {Color.apply(url, Color.CYAN):<59}\n" menu += f"{idx}) {Color.apply(url, Color.CYAN):<59}\n"
menu += f"{Color.apply(branch, Color.CYAN):<59}\n" menu += f"{Color.apply(branch, Color.CYAN):<59}\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
menu += "║ A) Add repository ║\n"
menu += "║ R) Remove repository ║\n"
menu += "╟───────────────────────────────────────────────────────╢\n" menu += "╟───────────────────────────────────────────────────────╢\n"
print(menu, end="") print(menu, end="")
@@ -77,3 +81,82 @@ class RepoSelectMenu(BaseMenu):
f"Switching to {self.name.capitalize()}'s new source repository ..." f"Switching to {self.name.capitalize()}'s new source repository ..."
) )
run_switch_repo_routine(self.name, repo.url, repo.branch) run_switch_repo_routine(self.name, repo.url, repo.branch)
def add_repository(self, **kwargs) -> None:
while True:
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Enter the repository URL",
content=[
"NOTE: There is no input validation in place, "
"please check your input for correctness",
],
)
url = get_string_input("Repository URL", allow_special_chars=True).strip()
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Enter the branch name",
content=[ "Press Enter to use the default branch (master)." ],
center_content=False,
)
branch = get_string_input("Branch", allow_special_chars=True, default="master").strip()
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Summary",
content=[
f"● URL: {url}",
f"● Branch: {branch}",
],
)
confirm = get_confirm("Save repository")
if confirm:
repo = Repository(url, branch)
if self.name == "klipper":
self.settings.klipper.repositories.append(repo)
self.settings.save()
self.repos = self.settings.klipper.repositories
else:
self.settings.moonraker.repositories.append(repo)
self.settings.save()
self.repos = self.settings.moonraker.repositories
Logger.print_ok("Repository added and saved.")
# Refresh menu to show new repo immediately and update options
self.set_options()
self.run()
break
else:
Logger.print_info("Operation cancelled by user.")
break
def remove_repository(self, **kwargs) -> None:
repos = self.repos
if not repos:
Logger.print_info("No repositories configured.")
return
repo_lines = [f"{idx}) {repo.url} [{repo.branch}]" for idx, repo in enumerate(repos, start=1)]
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Available Repositories",
content=[*repo_lines],
)
idx = get_number_input("Select the repository to remove", 1, len(repos))
removed = repos.pop(idx - 1)
if self.name == "klipper":
self.settings.klipper.repositories = repos
self.settings.save()
self.repos = self.settings.klipper.repositories
else:
self.settings.moonraker.repositories = repos
self.settings.save()
self.repos = self.settings.moonraker.repositories
Logger.print_ok(f"Removed repository: {removed.url} [{removed.branch}]")
# Refresh menu to show updated repo list and options
self.set_options()
self.run()
def go_back(self, **kwargs) -> None:
from core.menus.settings_menu import SettingsMenu
SettingsMenu().run()

View File

@@ -117,20 +117,12 @@ class SettingsMenu(BaseMenu):
) )
def switch_klipper_repo(self, **kwargs) -> None: def switch_klipper_repo(self, **kwargs) -> None:
name = "Klipper"
repos = self.settings.klipper.repositories repos = self.settings.klipper.repositories
if not repos: RepoSelectMenu("klipper", repos=repos, previous_menu=self.__class__).run()
self._warn_no_repos(name)
return
RepoSelectMenu(name.lower(), repos=repos, previous_menu=self.__class__).run()
def switch_moonraker_repo(self, **kwargs) -> None: def switch_moonraker_repo(self, **kwargs) -> None:
name = "Moonraker"
repos = self.settings.moonraker.repositories repos = self.settings.moonraker.repositories
if not repos: RepoSelectMenu("moonraker", repos=repos, previous_menu=self.__class__).run()
self._warn_no_repos(name)
return
RepoSelectMenu(name.lower(), repos=repos, previous_menu=self.__class__).run()
def toggle_mainsail_release(self, **kwargs) -> None: def toggle_mainsail_release(self, **kwargs) -> None:
self.mainsail_unstable = not self.mainsail_unstable self.mainsail_unstable = not self.mainsail_unstable

View File

@@ -8,14 +8,15 @@
# ======================================================================= # # ======================================================================= #
from __future__ import annotations from __future__ import annotations
import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, List from typing import Any, Callable, List, TypeVar
from components.klipper import KLIPPER_REPO_URL
from components.moonraker import MOONRAKER_REPO_URL
from core.backup_manager.backup_manager import BackupManager from core.backup_manager.backup_manager import BackupManager
from core.logger import DialogType, Logger from core.logger import DialogType, Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import ( from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
NoOptionError,
NoSectionError,
SimpleConfigParser, SimpleConfigParser,
) )
from utils.input_utils import get_confirm from utils.input_utils import get_confirm
@@ -26,13 +27,7 @@ from kiauh import PROJECT_ROOT
DEFAULT_CFG = PROJECT_ROOT.joinpath("default.kiauh.cfg") DEFAULT_CFG = PROJECT_ROOT.joinpath("default.kiauh.cfg")
CUSTOM_CFG = PROJECT_ROOT.joinpath("kiauh.cfg") CUSTOM_CFG = PROJECT_ROOT.joinpath("kiauh.cfg")
T = TypeVar("T")
class NoValueError(Exception):
"""Raised when a required value is not defined for an option"""
def __init__(self, section: str, option: str):
msg = f"Missing value for option '{option}' in section '{section}'"
super().__init__(msg)
class InvalidValueError(Exception): class InvalidValueError(Exception):
@@ -53,17 +48,20 @@ class Repository:
url: str url: str
branch: str branch: str
@dataclass @dataclass
class KlipperSettings: class KlipperSettings:
repositories: List[Repository] | None = field(default=None) repositories: List[Repository] | None = field(default=None)
use_python_binary: str | None = field(default=None) use_python_binary: str | None = field(default=None)
@dataclass @dataclass
class MoonrakerSettings: class MoonrakerSettings:
optional_speedups: bool | None = field(default=None) optional_speedups: bool | None = field(default=None)
repositories: List[Repository] | None = field(default=None) repositories: List[Repository] | None = field(default=None)
use_python_binary: str | None = field(default=None) use_python_binary: str | None = field(default=None)
@dataclass @dataclass
class WebUiSettings: class WebUiSettings:
port: int | None = field(default=None) port: int | None = field(default=None)
@@ -74,6 +72,7 @@ class WebUiSettings:
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class KiauhSettings: class KiauhSettings:
__instance = None __instance = None
__initialized = False
def __new__(cls, *args, **kwargs) -> "KiauhSettings": def __new__(cls, *args, **kwargs) -> "KiauhSettings":
if cls.__instance is None: if cls.__instance is None:
@@ -91,11 +90,10 @@ class KiauhSettings:
return getattr(self, item) return getattr(self, item)
def __init__(self) -> None: def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized: if self.__initialized:
return return
self.__initialized = True self.__initialized = True
self.config = SimpleConfigParser() self.config = SimpleConfigParser()
self.kiauh = AppSettings() self.kiauh = AppSettings()
self.klipper = KlipperSettings() self.klipper = KlipperSettings()
@@ -103,8 +101,9 @@ class KiauhSettings:
self.mainsail = WebUiSettings() self.mainsail = WebUiSettings()
self.fluidd = WebUiSettings() self.fluidd = WebUiSettings()
self._load_config() self.__read_config_set_internal_state()
# todo: refactor this, at least rename to something else!
def get(self, section: str, option: str) -> str | int | bool: def get(self, section: str, option: str) -> str | int | bool:
""" """
Get a value from the settings state by providing the section and option name as Get a value from the settings state by providing the section and option name as
@@ -122,135 +121,175 @@ class KiauhSettings:
raise raise
def save(self) -> None: def save(self) -> None:
self._set_config_options_state() self.__write_internal_state_to_cfg()
self.config.write_file(CUSTOM_CFG) self.__read_config_set_internal_state()
self._load_config()
def _load_config(self) -> None: def __read_config_set_internal_state(self) -> None:
if not CUSTOM_CFG.exists() and not DEFAULT_CFG.exists(): if not CUSTOM_CFG.exists() and not DEFAULT_CFG.exists():
self.__kill() Logger.print_dialog(
DialogType.ERROR,
cfg = CUSTOM_CFG if CUSTOM_CFG.exists() else DEFAULT_CFG [
self.config.read_file(cfg) "No KIAUH configuration file found! Please make sure you have at least "
"one of the following configuration files in KIAUH's root directory:",
needs_migration = self._check_deprecated_repo_config() "● default.kiauh.cfg",
if needs_migration: "● kiauh.cfg",
self._prompt_migration_dialog() ],
return )
else:
# Only validate if no migration was needed
self._validate_cfg()
self.__set_internal_state()
def _validate_cfg(self) -> None:
def __err_and_kill(error: str) -> None:
Logger.print_error(f"Error validating kiauh.cfg: {error}")
kill() kill()
try: # copy default config to custom config if it does not exist
self._validate_bool("kiauh", "backup_before_update") if not CUSTOM_CFG.exists():
shutil.copyfile(DEFAULT_CFG, CUSTOM_CFG)
self._validate_repositories("klipper", "repositories") self.config.read_file(CUSTOM_CFG)
self._validate_repositories("moonraker", "repositories")
self._validate_int("mainsail", "port") # check if there are deprecated repo_url and branch options in the kiauh.cfg
self._validate_bool("mainsail", "unstable_releases") if self._check_deprecated_repo_config():
self._prompt_migration_dialog()
self._validate_int("fluidd", "port") self.__set_internal_state()
self._validate_bool("fluidd", "unstable_releases")
self._validate_bool("moonraker", "optional_speedups")
except ValueError:
err = f"Invalid value for option '{self._v_option}' in section '{self._v_section}'"
__err_and_kill(err)
except NoSectionError:
err = f"Missing section '{self._v_section}' in config file"
__err_and_kill(err)
except NoOptionError:
err = f"Missing option '{self._v_option}' in section '{self._v_section}'"
__err_and_kill(err)
except NoValueError:
err = f"Missing value for option '{self._v_option}' in section '{self._v_section}'"
__err_and_kill(err)
def _validate_bool(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
(bool(self.config.getboolean(section, option)))
def _validate_int(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
int(self.config.getint(section, option))
def _validate_str(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
v = self.config.getval(section, option)
if not v:
raise ValueError
def _validate_repositories(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
repos = self.config.getval(section, option)
if not repos:
raise NoValueError(section, option)
for repo in repos:
if repo.strip().startswith("#") or repo.strip().startswith(";"):
continue
try:
if "," in repo:
url, branch = repo.strip().split(",")
if not url:
raise InvalidValueError(section, option, repo)
else:
url = repo.strip()
if not url:
raise InvalidValueError(section, option, repo)
except ValueError:
raise InvalidValueError(section, option, repo)
def __set_internal_state(self) -> None: def __set_internal_state(self) -> None:
self.kiauh.backup_before_update = self.config.getboolean( # parse Kiauh options
"kiauh", "backup_before_update" self.kiauh.backup_before_update = self.__read_from_cfg(
"kiauh",
"backup_before_update",
self.config.getboolean,
False,
) )
self.moonraker.optional_speedups = self.config.getboolean("moonraker", "optional_speedups", True) # parse Klipper options
self.klipper.use_python_binary = self.__read_from_cfg(
kl_repos = self.config.getval("klipper", "repositories") "klipper",
self.klipper.repositories = self.__set_repo_state(kl_repos) "use_python_binary",
self.config.getval,
mr_repos = self.config.getval("moonraker", "repositories") None,
self.moonraker.repositories = self.__set_repo_state(mr_repos) True,
self.klipper.use_python_binary = self.config.getval("klipper", "use_python_binary", None)
self.moonraker.use_python_binary = self.config.getval("moonraker", "use_python_binary", None)
self.mainsail.port = self.config.getint("mainsail", "port")
self.mainsail.unstable_releases = self.config.getboolean(
"mainsail", "unstable_releases"
) )
self.fluidd.port = self.config.getint("fluidd", "port") kl_repos: List[str] = self.__read_from_cfg(
self.fluidd.unstable_releases = self.config.getboolean( "klipper",
"fluidd", "unstable_releases" "repositories",
self.config.getvals,
[KLIPPER_REPO_URL],
)
self.klipper.repositories = self.__set_repo_state("klipper", kl_repos)
# parse Moonraker options
self.moonraker.use_python_binary = self.__read_from_cfg(
"moonraker",
"use_python_binary",
self.config.getval,
None,
True,
)
self.moonraker.optional_speedups = self.__read_from_cfg(
"moonraker",
"optional_speedups",
self.config.getboolean,
True,
)
mr_repos: List[str] = self.__read_from_cfg(
"moonraker",
"repositories",
self.config.getvals,
[MOONRAKER_REPO_URL],
)
self.moonraker.repositories = self.__set_repo_state("moonraker", mr_repos)
# parse Mainsail options
self.mainsail.port = self.__read_from_cfg(
"mainsail",
"port",
self.config.getint,
80,
)
self.mainsail.unstable_releases = self.__read_from_cfg(
"mainsail",
"unstable_releases",
self.config.getboolean,
False,
) )
def __set_repo_state(self, repos: List[str]) -> List[Repository]: # parse Fluidd options
self.fluidd.port = self.__read_from_cfg(
"fluidd",
"port",
self.config.getint,
80,
)
self.fluidd.unstable_releases = self.__read_from_cfg(
"fluidd",
"unstable_releases",
self.config.getboolean,
False,
)
def __check_option_exists(
self, section: str, option: str, fallback: Any, silent: bool = False
) -> bool:
has_section = self.config.has_section(section)
has_option = self.config.has_option(section, option)
if not (has_section and has_option):
if not silent:
Logger.print_warn(
f"Option '{option}' in section '{section}' not defined. Falling back to '{fallback}'."
)
return False
return True
def __read_bool_from_cfg(
self,
section: str,
option: str,
fallback: bool | None = None,
silent: bool = False,
) -> bool | None:
if not self.__check_option_exists(section, option, fallback, silent):
return fallback
return self.config.getboolean(section, option, fallback)
def __read_from_cfg(
self,
section: str,
option: str,
getter: Callable[[str, str, T | None], T],
fallback: T = None,
silent: bool = False,
) -> T:
if not self.__check_option_exists(section, option, fallback, silent):
return fallback
return getter(section, option, fallback)
def __set_repo_state(self, section: str, repos: List[str]) -> List[Repository]:
_repos: List[Repository] = [] _repos: List[Repository] = []
for repo in repos: for repo in repos:
if repo.strip().startswith("#") or repo.strip().startswith(";"): try:
continue if repo.strip().startswith("#") or repo.strip().startswith(";"):
if "," in repo: continue
url, branch = repo.strip().split(",") if "," in repo:
if not branch: url, branch = repo.strip().split(",")
if not branch:
branch = "master"
else:
url = repo.strip()
branch = "master" branch = "master"
else:
url = repo.strip() # url must not be empty otherwise it's considered
branch = "master" # as an unrecoverable, invalid configuration
_repos.append(Repository(url.strip(), branch.strip())) if not url:
raise InvalidValueError(section, "repositories", repo)
_repos.append(Repository(url.strip(), branch.strip()))
except InvalidValueError as e:
Logger.print_error(f"Error parsing kiauh.cfg: {e}")
kill()
return _repos return _repos
def _set_config_options_state(self) -> None: def __write_internal_state_to_cfg(self) -> None:
"""Updates the config with current settings, preserving values that haven't been modified""" """Updates the config with current settings, preserving values that haven't been modified"""
if self.kiauh.backup_before_update is not None: if self.kiauh.backup_before_update is not None:
self.config.set_option( self.config.set_option(
@@ -288,6 +327,8 @@ class KiauhSettings:
"fluidd", "unstable_releases", str(self.fluidd.unstable_releases) "fluidd", "unstable_releases", str(self.fluidd.unstable_releases)
) )
self.config.write_file(CUSTOM_CFG)
def _check_deprecated_repo_config(self) -> bool: def _check_deprecated_repo_config(self) -> bool:
# repo_url and branch are deprecated - 2025.03.23 # repo_url and branch are deprecated - 2025.03.23
for section in ["klipper", "moonraker"]: for section in ["klipper", "moonraker"]:
@@ -299,22 +340,23 @@ class KiauhSettings:
def _prompt_migration_dialog(self) -> None: def _prompt_migration_dialog(self) -> None:
migration_1: List[str] = [ migration_1: List[str] = [
"The old 'repo_url' and 'branch' options are now combined under 'repositories'.", "Options 'repo_url' and 'branch' are now combined into a 'repositories' option.",
"\n\n", "\n\n",
"Example format:", "● Old format:",
"[klipper]", " [klipper]",
"repositories:", " repo_url: https://github.com/Klipper3d/klipper",
" https://github.com/Klipper3d/klipper, master", " branch: master",
"\n\n", "\n\n",
"[moonraker]", "● New format:",
"repositories:", " [klipper]",
" https://github.com/Arksine/moonraker, master", " repositories:",
" https://github.com/Klipper3d/klipper, master",
] ]
Logger.print_dialog( Logger.print_dialog(
DialogType.ATTENTION, DialogType.ATTENTION,
[ [
"Deprecated repository configuration found!", "Deprecated kiauh.cfg configuration found!",
"KAIUH can now attempt to automatically migrate your configuration.", "KAIUH can now attempt to automatically migrate the configuration.",
"\n\n", "\n\n",
*migration_1, *migration_1,
], ],
@@ -325,7 +367,7 @@ class KiauhSettings:
Logger.print_dialog( Logger.print_dialog(
DialogType.ERROR, DialogType.ERROR,
[ [
"Please update your configuration file manually.", "Please update the configuration file manually.",
], ],
center_content=True, center_content=True,
) )
@@ -366,23 +408,7 @@ class KiauhSettings:
self.config.write_file(CUSTOM_CFG) self.config.write_file(CUSTOM_CFG)
self.config.read_file(CUSTOM_CFG) # reload config self.config.read_file(CUSTOM_CFG) # reload config
# Validate the migrated config
self._validate_cfg()
self.__set_internal_state()
except Exception as e: except Exception as e:
Logger.print_error(f"Error migrating configuration: {e}") Logger.print_error(f"Error migrating configuration: {e}")
Logger.print_error("Please migrate manually.") Logger.print_error("Please migrate manually.")
kill() kill()
def __kill(self) -> None:
Logger.print_dialog(
DialogType.ERROR,
[
"No KIAUH configuration file found! Please make sure you have at least "
"one of the following configuration files in KIAUH's root directory:",
"● default.kiauh.cfg",
"● kiauh.cfg",
],
)
kill()

View File

@@ -314,9 +314,7 @@ class SimpleConfigParser:
elements.pop(i) elements.pop(i)
break break
def getval( def getval(self, section: str, option: str, fallback: str | _UNSET = _UNSET) -> str:
self, section: str, option: str, fallback: str | _UNSET = _UNSET
) -> str | List[str]:
""" """
Return the value of the given option in the given section Return the value of the given option in the given section
@@ -329,22 +327,34 @@ class SimpleConfigParser:
if option not in self.get_options(section): if option not in self.get_options(section):
raise NoOptionError(option, section) raise NoOptionError(option, section)
# Find the option in the elements list
for element in self.config[section]["elements"]: for element in self.config[section]["elements"]:
if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value] and element["name"] == option: if element["type"] is LineType.OPTION.value and element["name"] == option:
raw_value = element["value"] return str(element["value"].strip().replace("\n", ""))
if isinstance(raw_value, str) and raw_value.endswith("\n"): return ""
return raw_value[:-1].strip()
elif isinstance(raw_value, list): except (NoSectionError, NoOptionError):
values: List[str] = [] if fallback is _UNSET:
for i, val in enumerate(raw_value): raise
val = val.strip().strip("\n") return fallback
if len(val) < 1:
continue def getvals(self, section: str, option: str, fallback: List[str] | _UNSET = _UNSET) -> List[str]:
values.append(val.strip()) """
return values Return the values of the given multi-line option in the given section
return str(raw_value)
raise NoOptionError(option, section) If the key is not found and 'fallback' is provided, it is used as
a fallback value.
"""
try:
if section not in self.get_sections():
raise NoSectionError(section)
if option not in self.get_options(section):
raise NoOptionError(option, section)
for element in self.config[section]["elements"]:
if element["type"] is LineType.OPTION_BLOCK.value and element["name"] == option:
return [val.strip() for val in element["value"] if val.strip()]
return []
except (NoSectionError, NoOptionError): except (NoSectionError, NoOptionError):
if fallback is _UNSET: if fallback is _UNSET:
raise raise

View File

@@ -51,7 +51,7 @@ def test_getval(parser):
assert parser.getval("section_2", "option_2") == "value_2" assert parser.getval("section_2", "option_2") == "value_2"
# test multiline option values # test multiline option values
ml_val = parser.getval("section number 5", "multi_option") ml_val = parser.getvals("section number 5", "multi_option")
assert isinstance(ml_val, list) assert isinstance(ml_val, list)
assert len(ml_val) > 0 assert len(ml_val) > 0
@@ -164,7 +164,7 @@ def test_set_new_option(parser):
assert parser.getval("new_section", "very_new_option") == "very_new_value" assert parser.getval("new_section", "very_new_option") == "very_new_value"
parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"]) parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"])
assert parser.getval("section_2", "array_option") == [ assert parser.getvals("section_2", "array_option") == [
"value_1", "value_1",
"value_2", "value_2",
"value_3", "value_3",

View File

@@ -143,6 +143,31 @@ class ExtensionSubmenu(BaseMenu):
""" """
)[1:] )[1:]
menu += f"{description_text}\n" menu += f"{description_text}\n"
# add links if available
website: str = (self.extension.metadata.get("website") or "").strip()
repo: str = (self.extension.metadata.get("repo") or "").strip()
if website or repo:
links_lines: List[str] = ["Links:"]
if website:
links_lines.append(f"- Website: {website}")
if repo:
links_lines.append(f"- GitHub: {repo}")
links_text = Logger.format_content(
links_lines,
line_width,
border_left="",
border_right="",
)
menu += textwrap.dedent(
"""
╟───────────────────────────────────────────────────────╢
"""
)[1:]
menu += f"{links_text}\n"
menu += textwrap.dedent( menu += textwrap.dedent(
""" """
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢

View File

@@ -0,0 +1,22 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 pathlib import Path
# Constants
OP_DEFAULT_PORT = 5000
# OctoPrint instance naming/prefixes
OP_ENV_PREFIX = "OctoPrint"
OP_BASEDIR_PREFIX = ".octoprint"
# Service/log filenames
OP_LOG_NAME = "octoprint.log"
# Files/paths (computed per-instance where applicable)
OP_SUDOERS_FILE = Path("/etc/sudoers.d/octoprint-shutdown")

View File

@@ -0,0 +1,18 @@
{
"metadata": {
"index": 12,
"module": "octoprint_extension",
"maintained_by": "dw-0",
"display_name": "OctoPrint",
"description": [
"Open-source web interface to control and monitor your 3D printer",
"- Upload and manage G-code, start/pause/cancel prints",
"- Live webcam view and timelapse support",
"- Real-time temperature graphs and printer status",
"- Powerful plugin ecosystem"
],
"website": "https://octoprint.org",
"repo": "https://github.com/OctoPrint/OctoPrint",
"updates": false
}
}

View File

@@ -0,0 +1,116 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 textwrap import dedent
from components.klipper.klipper import Klipper
from core.constants import CURRENT_USER
from core.instance_manager.base_instance import BaseInstance
from core.logger import Logger
from extensions.octoprint import (
OP_BASEDIR_PREFIX,
OP_ENV_PREFIX,
OP_LOG_NAME,
)
from utils.fs_utils import create_folders
from utils.sys_utils import create_service_file, get_service_file_path
@dataclass
class Octoprint:
suffix: str
base: BaseInstance = field(init=False, repr=False)
service_file_path: Path = field(init=False)
log_file_name = OP_LOG_NAME
env_dir: Path = field(init=False)
basedir: Path = field(init=False)
cfg_file: Path = field(init=False)
def __post_init__(self):
self.base = BaseInstance(Klipper, self.suffix)
self.base.log_file_name = self.log_file_name
self.service_file_path = get_service_file_path(Octoprint, self.suffix)
# OctoPrint stores its data under ~/.octoprint[_SUFFIX]
self.basedir = (
Path.home().joinpath(OP_BASEDIR_PREFIX)
if self.suffix == ""
else Path.home().joinpath(f"{OP_BASEDIR_PREFIX}_{self.suffix}")
)
self.cfg_file = self.basedir.joinpath("config.yaml")
# OctoPrint virtualenv lives under ~/OctoPrint[_SUFFIX]
self.env_dir = (
Path.home().joinpath(OP_ENV_PREFIX)
if self.suffix == ""
else Path.home().joinpath(f"{OP_ENV_PREFIX}_{self.suffix}")
)
def create(self, port: int) -> None:
Logger.print_status(
f"Creating OctoPrint instance '{self.service_file_path.stem}' ..."
)
# Ensure basedir exists and config.yaml is present
create_folders([self.basedir])
if not self.cfg_file.exists():
Logger.print_status("Creating config.yaml ...")
self.cfg_file.write_text(self._prep_config_yaml())
Logger.print_ok("config.yaml created!")
else:
Logger.print_info("config.yaml already exists. Skipped ...")
create_service_file(self.service_file_path.name, self._prep_service_content(port))
def _prep_service_content(self, port: int) -> str:
basedir = self.basedir.as_posix()
cfg = self.cfg_file.as_posix()
octo_exec = self.env_dir.joinpath("bin/octoprint").as_posix()
return dedent(
f"""\
[Unit]
Description=Starts OctoPrint on startup
After=network-online.target
Wants=network-online.target
[Service]
Environment="LC_ALL=C.UTF-8"
Environment="LANG=C.UTF-8"
Type=simple
User={CURRENT_USER}
ExecStart={octo_exec} --basedir {basedir} --config {cfg} --port={port} serve
[Install]
WantedBy=multi-user.target
"""
)
def _prep_config_yaml(self) -> str:
printer = self.base.comms_dir.joinpath("klippy.serial").as_posix()
restart_service = self.service_file_path.stem
return dedent(
f"""\
serial:
additionalPorts:
- {printer}
disconnectOnErrors: false
port: {printer}
server:
commands:
serverRestartCommand: sudo service {restart_service} restart
systemRestartCommand: sudo shutdown -r now
systemShutdownCommand: sudo shutdown -h now
"""
)

View File

@@ -0,0 +1,286 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 re
from typing import Dict, List, Optional, Set
from components.klipper.klipper import Klipper
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.types.color import Color
from core.menus.base_menu import print_back_footer
from extensions.base_extension import BaseExtension
from extensions.octoprint import (
OP_SUDOERS_FILE, OP_DEFAULT_PORT,
)
from extensions.octoprint.octoprint import Octoprint
from utils.common import check_install_dependencies
from utils.fs_utils import run_remove_routines, remove_with_sudo
from utils.input_utils import get_selection_input, get_confirm
from utils.instance_utils import get_instances
from utils.sys_utils import (
create_python_venv,
get_ipv4_addr,
install_python_packages,
)
# noinspection PyMethodMayBeStatic
class OctoprintExtension(BaseExtension):
def install_extension(self, **kwargs) -> None:
Logger.print_status("Installing OctoPrint ...")
klipper_instances: List[Klipper] = get_instances(Klipper)
if not klipper_instances:
Logger.print_dialog(
DialogType.WARNING,
[
"Klipper not found! Please install Klipper first.",
],
)
return
existing_ops: List[Octoprint] = get_instances(Octoprint)
existing_by_suffix: Dict[str, Octoprint] = {op.suffix: op for op in existing_ops}
candidates: List[Klipper] = [k for k in klipper_instances if k.suffix not in existing_by_suffix]
chosen: List[Klipper] = []
if len(klipper_instances) == 1:
k = klipper_instances[0]
if k.suffix in existing_by_suffix:
if not get_confirm(
f"OctoPrint already exists for '{k.service_file_path.stem}'. Reinstall?",
default_choice=True,
allow_go_back=True,
):
Logger.print_info("Aborted OctoPrint installation.")
return
chosen = [k]
else:
while True:
dialog = "╔═══════════════════════════════════════════════════════╗\n"
headline = Color.apply(
"The following Klipper instances were found:", Color.GREEN
)
dialog += f"{headline:^64}\n"
dialog += "╟───────────────────────────────────────────────────────╢\n"
if candidates:
line_all = Color.apply("a) Select all (install for all missing)", Color.YELLOW)
dialog += f"{line_all:<63}\n"
dialog += "║ ║\n"
index_map: Dict[str, Klipper] = {}
for i, k in enumerate(klipper_instances, start=1):
mapping = existing_by_suffix.get(k.suffix)
suffix = f" <-> {mapping.service_file_path.stem}" if mapping else ""
line = Color.apply(f"{i}) {k.service_file_path.stem}{suffix}", Color.CYAN)
dialog += f"{line:<63}\n"
index_map[str(i)] = k
dialog += "╟───────────────────────────────────────────────────────╢\n"
print(dialog, end="")
print_back_footer()
allowed = list(index_map.keys()) + ["b"] + (["a"] if candidates else [])
choice = get_selection_input("Choose instance to install OctoPrint for", allowed)
if choice == "b":
Logger.print_info("Aborted OctoPrint installation.")
return
if choice == "a":
chosen = candidates
break
selected = index_map[choice]
if selected.suffix in existing_by_suffix:
confirm = get_confirm(
f"OctoPrint already exists for '{selected.service_file_path.stem}'. Reinstall?",
default_choice=True,
allow_go_back=True,
)
if not confirm:
# back to menu
continue
chosen = [selected]
break
deps = {
"git",
"wget",
"python3-pip",
"python3-dev",
"libyaml-dev",
"build-essential",
"python3-setuptools",
"python3-virtualenv",
}
check_install_dependencies(deps)
# Determine used ports from existing OctoPrint services and prepare regex
used_ports: Set[int] = set()
port_re = re.compile(r"--port=(\d+)")
for op in existing_ops:
try:
content = op.service_file_path.read_text()
m = port_re.search(content)
if m:
used_ports.add(int(m.group(1)))
except OSError:
pass
# noinspection PyShadowingNames
def read_existing_port(suffix: str) -> Optional[int]:
op = existing_by_suffix.get(suffix)
if not op:
return None
try:
content = op.service_file_path.read_text()
m = port_re.search(content)
return int(m.group(1)) if m else None
except OSError:
return None
def next_free_port(start: int, used: Set[int]) -> int:
p = start
while p in used:
p += 1
used.add(p)
return p
created_ops: List[Octoprint] = []
for k in chosen:
# Keep existing port on reinstall, otherwise assign next free one
existing_port = read_existing_port(k.suffix)
port = existing_port if existing_port is not None else next_free_port(OP_DEFAULT_PORT, used_ports)
instance = Octoprint(suffix=k.suffix)
if create_python_venv(instance.env_dir, force=False):
Logger.print_ok(
f"Virtualenv created: {instance.env_dir}", prefix=False
)
else:
Logger.print_info(
f"Virtualenv exists: {instance.env_dir}. Skipping creation ..."
)
install_python_packages(instance.env_dir, ["octoprint"])
instance.create(port=port)
created_ops.append(instance)
for inst in created_ops:
try:
InstanceManager.enable(inst)
InstanceManager.start(inst)
except Exception as e:
Logger.print_error(
f"Failed to enable/start {inst.service_file_path.name}: {e}"
)
ip = get_ipv4_addr()
lines = ["Access your new OctoPrint instance(s) at:"]
for inst in created_ops:
try:
content = inst.service_file_path.read_text()
m = port_re.search(content)
if m:
# noinspection HttpUrlsUsage
lines.append(f"{inst.service_file_path.stem}: http://{ip}:{m.group(1)}")
except OSError:
pass
Logger.print_dialog(DialogType.SUCCESS, lines, center_content=False)
def remove_extension(self, **kwargs) -> None:
Logger.print_status("Removing OctoPrint ...")
try:
op_instances: List[Octoprint] = get_instances(Octoprint)
if not op_instances:
Logger.print_info("No OctoPrint instances found. Skipped ...")
return
remove_all = False
if len(op_instances) == 1:
to_remove = op_instances
else:
dialog = "╔═══════════════════════════════════════════════════════╗\n"
headline = Color.apply(
"The following OctoPrint instances were found:", Color.GREEN
)
dialog += f"{headline:^64}\n"
dialog += "╟───────────────────────────────────────────────────────╢\n"
select_all = Color.apply("a) Select all", Color.YELLOW)
dialog += f"{select_all:<63}\n"
dialog += "║ ║\n"
for i, inst in enumerate(op_instances, start=1):
line = Color.apply(
f"{i}) {inst.service_file_path.stem}", Color.CYAN
)
dialog += f"{line:<63}\n"
dialog += "╟───────────────────────────────────────────────────────╢\n"
print(dialog, end="")
print_back_footer()
allowed = [str(i) for i in range(1, len(op_instances) + 1)]
allowed.extend(["a", "b"])
choice = get_selection_input("Choose instance to remove", allowed)
if choice == "a":
remove_all = True
to_remove = op_instances
elif choice == "b":
Logger.print_info("Aborted OctoPrint removal.")
return
else:
idx = int(choice) - 1
to_remove = [op_instances[idx]]
for inst in to_remove:
Logger.print_status(
f"Removing instance {inst.service_file_path.stem} ..."
)
try:
InstanceManager.remove(inst)
except Exception as e:
Logger.print_error(
f"Failed to remove service {inst.service_file_path.name}: {e}"
)
# Remove only this instance's env and basedir
if inst.env_dir.exists():
Logger.print_status(f"Removing {inst.env_dir} ...")
run_remove_routines(inst.env_dir)
if inst.basedir.exists():
Logger.print_status(f"Removing {inst.basedir} ...")
run_remove_routines(inst.basedir)
# Remove sudoers file only if no instances remain
remaining = get_instances(Octoprint)
if not remaining and OP_SUDOERS_FILE.exists():
Logger.print_status(f"Removing {OP_SUDOERS_FILE} ...")
remove_with_sudo(OP_SUDOERS_FILE)
Logger.print_dialog(
DialogType.SUCCESS,
[
"Selected OctoPrint instance(s) successfully removed!"
if not remove_all
else "All OctoPrint instances successfully removed!",
],
center_content=True,
)
except Exception as e:
Logger.print_error(f"Error during OctoPrint removal: {e}")

View File

@@ -42,10 +42,13 @@ from utils.sys_utils import (
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 :return: string of the latest tag or a default value if no tags exist
""" """
lastest_tag: str = get_local_tags(Path(__file__).parent.parent)[-1] tags = get_local_tags(Path(__file__).parent.parent)
return lastest_tag if tags:
return tags[-1]
else:
return "v?.?.?"
def convert_camelcase_to_kebabcase(name: str) -> str: def convert_camelcase_to_kebabcase(name: str) -> str:

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple, Union
from core.logger import Logger from core.logger import Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import ( from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
@@ -19,7 +19,7 @@ from core.submodules.simple_config_parser.src.simple_config_parser.simple_config
) )
from utils.instance_type import InstanceType from utils.instance_type import InstanceType
ConfigOption = Tuple[str, str] ConfigOption = Tuple[str, Union[str, List[str]]]
def add_config_section( def add_config_section(

View File

@@ -15,6 +15,7 @@ from extensions.obico.moonraker_obico import MoonrakerObico
from extensions.octoeverywhere.octoeverywhere import Octoeverywhere 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.telegram_bot.moonraker_telegram_bot import MoonrakerTelegramBot
from extensions.octoprint.octoprint import Octoprint
InstanceType = TypeVar( InstanceType = TypeVar(
"InstanceType", "InstanceType",
@@ -24,4 +25,5 @@ InstanceType = TypeVar(
MoonrakerObico, MoonrakerObico,
Octoeverywhere, Octoeverywhere,
Octoapp, Octoapp,
Octoprint,
) )

View File

@@ -184,9 +184,6 @@ def install_python_requirements(target: Path, requirements: Path) -> None:
:return: None :return: None
""" """
try: try:
# always update pip before installing requirements
update_python_pip(target)
Logger.print_status("Installing Python requirements ...") Logger.print_status("Installing Python requirements ...")
command = [ command = [
target.joinpath("bin/pip").as_posix(), target.joinpath("bin/pip").as_posix(),
@@ -196,7 +193,7 @@ def install_python_requirements(target: Path, requirements: Path) -> None:
] ]
result = run(command, stderr=PIPE, text=True) result = run(command, stderr=PIPE, text=True)
if result.returncode != 0 or result.stderr: if result.returncode != 0:
Logger.print_error(f"{result.stderr}", False) Logger.print_error(f"{result.stderr}", False)
raise VenvCreationFailedException("Installing Python requirements failed!") raise VenvCreationFailedException("Installing Python requirements failed!")
@@ -216,9 +213,6 @@ def install_python_packages(target: Path, packages: List[str]) -> None:
:return: None :return: None
""" """
try: try:
# always update pip before installing requirements
update_python_pip(target)
Logger.print_status("Installing Python requirements ...") Logger.print_status("Installing Python requirements ...")
command = [ command = [
target.joinpath("bin/pip").as_posix(), target.joinpath("bin/pip").as_posix(),
@@ -228,7 +222,7 @@ def install_python_packages(target: Path, packages: List[str]) -> None:
command.append(pkg) command.append(pkg)
result = run(command, stderr=PIPE, text=True) result = run(command, stderr=PIPE, text=True)
if result.returncode != 0 or result.stderr: if result.returncode != 0:
Logger.print_error(f"{result.stderr}", False) Logger.print_error(f"{result.stderr}", False)
raise VenvCreationFailedException("Installing Python requirements failed!") raise VenvCreationFailedException("Installing Python requirements failed!")

84
mkdocs.yml Normal file
View File

@@ -0,0 +1,84 @@
site_name: KIAUH Documentation
site_description: Documentation for the Klipper Installation And Update Helper
repo_url: https://github.com/dw-0/kiauh
repo_name: dw-0/kiauh
edit_uri: edit/master/docs
copyright: Copyright &copy; 2025 Dominik Willner
theme:
name: material
logo: assets/logo.png
favicon: assets/logo.png
icon:
repo: fontawesome/brands/github
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: blue-grey
accent: cyan
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: blue-grey
accent: cyan
toggle:
icon: material/weather-sunny
name: Switch to light mode
features:
- navigation.instant
- navigation.tracking
- navigation.sections
- navigation.expand
- navigation.indexes
- navigation.top
- toc.follow
- content.code.copy
plugins:
- search
- git-revision-date-localized:
enable_creation_date: true
- mkdocstrings:
handlers:
python:
paths: [.]
options:
docstring_style: google
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- tables
- attr_list
- md_in_html
nav:
- Home: index.md
- Installation:
- setup/raspberry-pi-setup.md
- setup/installation.md
- Configuration: configuration.md
- Extensions:
- extensions/index.md
- extensions/gcode-shell-command.md
- Development:
- development/contributing.md
- development/changelog.md
extra:
social:
- icon: simple/github
link: https://github.com/dw-0
- icon: simple/kofi
link: https://ko-fi.com/dw__0
- icon: simple/paypal
link: https://www.paypal.com/paypalme/dwillner0

View File

@@ -2,7 +2,7 @@
requires-python = ">=3.8" requires-python = ">=3.8"
[project.optional-dependencies] [project.optional-dependencies]
dev=["ruff", "mypy"] dev=["ruff", "pyright"]
[tool.ruff] [tool.ruff]
required-version = ">=0.9.10" required-version = ">=0.9.10"
@@ -20,14 +20,3 @@ quote-style = "double"
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["I"] extend-select = ["I"]
[tool.mypy]
python_version = "3.8"
platform = "linux"
# strict = true # TODO: enable this once everything is else is handled
check_untyped_defs = true
ignore_missing_imports = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true

6
pyrightconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"pythonVersion": "3.8",
"pythonPlatform": "Linux",
"typeCheckingMode": "standard",
"venvPath": "./.kiauh-env"
}

2
requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
ruff (>=0.9.10)
pyright

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
mkdocs-material
mkdocs
mkdocstrings[python]
mkdocs-git-revision-date-localized-plugin

View File

@@ -280,7 +280,6 @@ function create_klipper_virtualenv() {
status_msg "Installing $("python${python_version}" -V) virtual environment..." status_msg "Installing $("python${python_version}" -V) virtual environment..."
if virtualenv -p "python${python_version}" "${KLIPPY_ENV}"; then if virtualenv -p "python${python_version}" "${KLIPPY_ENV}"; then
(( python_version == 3 )) && "${KLIPPY_ENV}"/bin/pip install -U pip
"${KLIPPY_ENV}"/bin/pip install -r "${KLIPPER_DIR}"/scripts/klippy-requirements.txt "${KLIPPY_ENV}"/bin/pip install -r "${KLIPPER_DIR}"/scripts/klippy-requirements.txt
else else
log_error "failure while creating python3 klippy-env" log_error "failure while creating python3 klippy-env"

View File

@@ -126,7 +126,7 @@ function update_klipperscreen() {
git checkout -f master && ok_msg "Checkout successfull" git checkout -f master && ok_msg "Checkout successfull"
if [[ $(md5sum "${KLIPPERSCREEN_DIR}/scripts/KlipperScreen-requirements.txt" | cut -d " " -f1) != "${old_md5}" ]]; then if [[ $(md5sum "${KLIPPERSCREEN_DIR}/scripts/KlipperScreen-requirements.txt" | cut -d " " -f1) != "${old_md5}" ]]; then
status_msg "New dependecies detected..." status_msg "New dependencies detected..."
"${KLIPPERSCREEN_ENV}"/bin/pip install -r "${KLIPPERSCREEN_DIR}/scripts/KlipperScreen-requirements.txt" "${KLIPPERSCREEN_ENV}"/bin/pip install -r "${KLIPPERSCREEN_DIR}/scripts/KlipperScreen-requirements.txt"
ok_msg "Dependencies have been installed!" ok_msg "Dependencies have been installed!"
fi fi

View File

@@ -133,7 +133,7 @@ function update_mobileraker() {
git checkout -f main && ok_msg "Checkout successfull" git checkout -f main && ok_msg "Checkout successfull"
if [[ $(md5sum "${MOBILERAKER_DIR}/scripts/mobileraker-requirements.txt" | cut -d " " -f1) != "${old_md5}" ]]; then if [[ $(md5sum "${MOBILERAKER_DIR}/scripts/mobileraker-requirements.txt" | cut -d " " -f1) != "${old_md5}" ]]; then
status_msg "New dependecies detected..." status_msg "New dependencies detected..."
"${MOBILERAKER_ENV}"/bin/pip install -r "${MOBILERAKER_DIR}/scripts/mobileraker-requirements.txt" "${MOBILERAKER_ENV}"/bin/pip install -r "${MOBILERAKER_DIR}/scripts/mobileraker-requirements.txt"
ok_msg "Dependencies have been installed!" ok_msg "Dependencies have been installed!"
fi fi

View File

@@ -336,7 +336,6 @@ function create_moonraker_virtualenv() {
[[ -d ${MOONRAKER_ENV} ]] && rm -rf "${MOONRAKER_ENV}" [[ -d ${MOONRAKER_ENV} ]] && rm -rf "${MOONRAKER_ENV}"
if virtualenv -p /usr/bin/python3 "${MOONRAKER_ENV}"; then if virtualenv -p /usr/bin/python3 "${MOONRAKER_ENV}"; then
"${MOONRAKER_ENV}"/bin/pip install -U pip
"${MOONRAKER_ENV}"/bin/pip install -r "${MOONRAKER_DIR}/scripts/moonraker-requirements.txt" "${MOONRAKER_ENV}"/bin/pip install -r "${MOONRAKER_DIR}/scripts/moonraker-requirements.txt"
else else
log_error "failure while creating python3 moonraker-env" log_error "failure while creating python3 moonraker-env"