Compare commits

..

45 Commits

Author SHA1 Message Date
dw-0
a63cf8c9d9 Release v6.0.0-alpha.9
Merge develop into master (Release v6.0.0-alpha.9)
2024-10-24 12:29:24 +02:00
dw-0
02ed3e7da0 feat: show actual current branch in settings menu (#588)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-24 12:26:18 +02:00
dw-0
4427ae94af fix: make sure all output is utf-8 encoded (#587)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-24 12:26:01 +02:00
dw-0
81b7b156b9 feat: implement port reconfiguration for webclients (#586)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-24 12:25:44 +02:00
dw-0
2df364512b fix: Path.rename() not working across devices (#584)
causes `[Errno 18] Invalid cross-device link` on tmpfs filesystems

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-24 12:25:22 +02:00
dw-0
dfa0036326 refactor: don't clear scrollback on clear
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-24 12:24:05 +02:00
dw-0
425d86a12f Release v6.0.0-alpha.8
Merge develop into master (Release v6.0.0-alpha.8)
2024-10-21 19:45:55 +02:00
dw-0
ff6162d799 refactor: do not silently configure Fluidd for port 81 (#582)
* refactor: use port 80 as default for fluidd

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: improve port selection logic, write last port selection for client to kiauh.cfg

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-21 19:30:03 +02:00
dw-0
674c174224 fix: correctly add Spoolman to moonraker.asvc (#581)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-20 18:11:30 +02:00
CODeRUS
a368331693 feat(flashing): Flash RP2040 in Boot Mode (#580)
* feat(flashing): Flash RP2040 in Boot Mode

* docs: add info about STM32 DFU and RP2040 boot modes
2024-10-18 18:56:21 +02:00
Pedro Lamas
406b64d1e5 refactor: add client name to Moonraker not found dialog (#574)
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2024-10-14 17:18:10 +02:00
dw-0
1b5691f2f5 Release v6.0.0-alpha.7
Merge develop into master (v6.0.0-alpha.7)

fixes #561
fixes #564
fixes #565
2024-10-13 11:51:19 +02:00
dw-0
e7eae5a0d1 fix: correctly handle IPs in nginx config files when parsing ports (#568)
* chore: add jupyter files to .gitignore

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* fix: correctly handle IPs in nginx config files when parsing ports

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-13 11:21:18 +02:00
dw-0
dc561a562c fix: always return string tuple from get_repo_name() (#567)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-13 10:27:31 +02:00
dw-0
55cfe124b2 feat: add SimplyPrint extension (#566)
* refactor: correctly sort extensions in extension menu

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: use different name for printer_data backup dir

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: change return type to List for moonraker_exists function

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* feat: add SimplyPrint extension

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-13 09:35:15 +02:00
Christian Würthner
43d6598be6 fix: remove octoapp_store dir when uninstalling (#562)
Co-authored-by: dw-0 <th33xitus@gmail.com>
2024-10-05 12:35:14 +02:00
dw-0
dc026a7a2b Release v6.0.0-alpha.6
Merge develop into master (v6.0.0-alpha.6)

fixes #545
fixes #553
fixes #557
2024-10-05 08:29:40 +02:00
dw-0
ac54d04b40 fix: correctly find connected UART devices (#559)
fixes #557

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-05 08:21:39 +02:00
dw-0
c19364360c fix: correctly find connected USB DFU devices (#555)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-05 08:07:47 +02:00
dw-0
2e6c66e524 fix: allow moonraker-telegram-bot-env access to systems site-packages dir (#556)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-10-03 10:22:32 +02:00
Christian Würthner
cd8003add9 feat(extension): add OctoApp (#554)
* Add OctoApp to v6

* fix: set correct index to new extension

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2024-10-03 08:51:38 +02:00
Kenneth Jiang
1f75395063 fix: self.cfg_file is already a full path (#552)
Signed-off-by: Kenneth Jiang <kenneth.jiang@gmail.com>
2024-09-29 20:33:54 +02:00
Kenneth Jiang
6e1bffa975 fix: remove "obico" from the suffix_blacklist so that it can discover its own instances. (#551)
Signed-off-by: Kenneth Jiang <kenneth.jiang@gmail.com>
2024-09-29 16:41:20 +02:00
dw-0
a8a73249a5 Release v6.0.0-alpha.5
Merge develop into master (v6.0.0-alpha.5)
2024-09-26 20:55:22 +02:00
dw-0
4138c71920 fix: fix section adding and exception handling (#548)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-26 20:52:19 +02:00
dw-0
ec3f93eeda Release v6.0.0-alpha.4
Merge develop into master (v6.0.0-alpha.4)
2024-09-22 09:43:04 +02:00
dw-0
afeb2bf02e feat: implement update all feature (#541)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-22 09:38:15 +02:00
dw-0
4b17c68454 fix: trunc owner and repo name if they would overflow (#540)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-22 08:58:44 +02:00
dw-0
df414ce37e fix: run umask 022 at launch (#538)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-21 21:01:19 +02:00
dw-0
975629f097 refactor: rework client config conflict detection (#537)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-21 18:42:19 +02:00
dw-0
fd2910ba67 fix: remove klipper.env and moonraker.env during removal (#536)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-21 15:10:30 +02:00
dw-0
6b6607c5ab fix: update scp integration for more robust config handling (#535)
* chore: remove scp

* Squashed 'kiauh/core/submodules/simple_config_parser/' content from commit abee21c

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: abee21c08658be4529028844304df60650c09afa

* Squashed 'kiauh/core/submodules/simple_config_parser/' changes from abee21c..aa0302b

aa0302b fix: fix missing newline chars in raw strings

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: aa0302b02b56b252ed88fd2db88ee878a5bb7b5b

* Squashed 'kiauh/core/submodules/simple_config_parser/' changes from aa0302b..ef52958

ef52958 refactor: conditionally add empty line when adding new section

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: ef529580f469ef020135cb03e250fcd4e0d70acf

* fix: update scp integration for more robust cfg modification

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-21 13:55:30 +02:00
CODeRUS
b604d93d0c fix: RP2040 firmware detection (#533)
Co-authored-by: dw-0 <th33xitus@gmail.com>
2024-09-21 12:10:20 +02:00
dw-0
7e87f8af32 refactor: implement Mobileraker and OctoEverywhere as community extensions (#532)
* refactor: move mobileraker to extensions

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: move octoeverywhere to extensions

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-20 12:05:29 +02:00
dw-0
29b5ab00cd fix: correctly point to printers config dir (#531)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-15 08:36:49 +02:00
dw-0
4cf523a758 Merge pull request #524 from dw-0/develop
Merge develop into master
2024-09-08 19:04:19 +02:00
dw-0
694a4c20c5 fix: typo in "origin" and "managed_services" (#520)
* fix: typo in "origin" and "managed_services" for klipperscreen update manager config

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* fix: typo in "origin" for moonraker telegram bot update manager config

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-08 18:58:07 +02:00
dw-0
a54514c400 fix: fix switching of repositories (#519)
* fix: fix repo switching

Extend the functionality of repo switching by creating a backup before the switch. Also implement a rollback mechanic in case of an error.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: fail when installing requirements fails

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: display owner and repo in main menu on separate lines

long owner and repo names would case the menu to be too wide

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-05 20:31:38 +02:00
dw-0
1d06bf76f3 Merge pull request #511 from dw-0/develop
Merge develop into master
2024-09-01 19:02:48 +02:00
dw-0
e438081c35 fix: update SimpleConfigParser submodule (#510) 2024-09-01 18:51:25 +02:00
dw-0
9f50f6fdd7 fix: y and n are invalid selections in KlipperFlashOverviewMenu (#508)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-09-01 18:31:15 +02:00
dw-0
0ee0fa3325 feat: KIAUH v6 - full rewrite of KIAUH in Python (#428) 2024-08-31 19:16:52 +02:00
Henrik Fransson
8547942986 readme: fix broken OctoApp plugin link (#494) 2024-08-06 16:41:18 +02:00
dw-0
d33ac6b15a fix: parse moonraker dependencies from system-dependencies.json (#492)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-08-03 13:30:34 +02:00
dw-0
6cd9133a15 fix: detect RatOS 2.1+ as operating system and exit (#490)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-07-31 20:30:33 +02:00
166 changed files with 7586 additions and 4235 deletions

View File

@@ -6,6 +6,10 @@ indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
end_of_line = lf
[*.py]
max_line_length = 88
[*.sh]
indent_size = 2

4
.gitignore vendored
View File

@@ -1,6 +1,10 @@
.idea
.vscode
.pytest_cache
.jupyter
*.ipynb
*.ipynb_checkpoints
*.tmp
__pycache__
.kiauh-env
*.code-workspace

View File

@@ -154,7 +154,7 @@ prompt and confirm by hitting ENTER.
<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/OctoPrint-OctoApp">OctoApp For Klipper</a></h3></th>
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
<th><h3></h3></th>
</tr>

View File

@@ -14,5 +14,5 @@ port: 80
unstable_releases: False
[fluidd]
port: 81
port: 80
unstable_releases: False

View File

@@ -2,13 +2,54 @@
This document covers possible important changes to KIAUH.
### 2024-08-31 (v6.0.0-alpha.1)
Long time no see, but here we are again!
A lot has happened in the background, but now it is time to take it out into the wild.
#### KIAUH has now reached version 6! Well, at least in an alpha state...
The project has seen a complete rewrite of the script from scratch in Python.
It requires Python 3.8 or newer to run. Because this update is still in an alpha state, bugs may or will occur.
During startup, you will be asked if you want to start the new version 6 or the old version 5.
As long as version 6 is in a pre-release state, version 5 will still be available. If there are any critical issues
with the new version that were overlooked, you can always switch back to the old version.
In case you selected not to get asked about which version to start (option 3 or 4 in the startup dialog) and you want to
revert that decision, you will find a line called `version_to_launch=` within the `.kiauh.ini` file in your home directory.
Just delete that line, save the file and restart KIAUH. KIAUH will then ask you again which version you want to start.
Here is a list of the most important changes to KIAUH in regard to version 6:
- The majority of features available in KIAUH v5 are still available; they just got migrated from Bash to Python.
- It is now possible to add new/remove instances to/from existing multi-instance installations of Klipper and Moonraker
- KIAUH now has an Extension-System. This allows contributors to add new installers to KIAUH without having to modify the main script.
- You will now find some of the features that were previously available in the Installer-Menu in the Extensions-Menu.
- The current extensions are:
- G-Code Shell Command (previously found in the Advanced-Menu)
- Mainsail Theme Installer (previously found in the Advanced-Menu)
- Klipper-Backup (new in v6!)
- Moonraker Telegram Bot (previously found in the Installer-Menu)
- PrettyGCode for Klipper (previously found in the Installer-Menu)
- Obico for Klipper (previously found in the Installer-Menu)
- The following additional extensions are planned, but not yet available:
- Spoolman (available in v5 in the Installer-Menu)
- OctoApp (available in v5 in the Installer-Menu)
- KIAUH has its own config file now
- The file has some default values for the currently supported options
- There might be more options in the future
- It is located in KIAUH's root directory and is called `default.kiauh.cfg`
- DO NOT EDIT the default file directly, instead make a copy of it and call it `kiauh.cfg`
- Settings changed via the Advanced-Menu will be written to the `kiauh.cfg`
- Support for OctoPrint was removed
Feel free to give version 6 a try and report any bugs or issues you encounter! Every feedback is appreciated.
### 2023-06-17
KIAUH has now added support for installing Mobileraker's companion!
KIAUH has now added support for installing Mobileraker's companion!
Mobileraker is a free and Open Source Android and iOS App for Klipper, utilizing the Moonraker API, allowing you
to control your printer. Thank you to [Clon1998](https://github.com/Clon1998) for adding this feature!
### 2023-02-03
The installer for MJPG-Streamer got replaced by crowsnest. It is an improved webcam service, utilizing ustreamer.
The installer for MJPG-Streamer got replaced by crowsnest. It is an improved webcam service, utilizing ustreamer.
Please have a look here for additional info about crowsnest and how to configure it: https://github.com/mainsail-crew/crowsnest \
It's unsure if the previous MJPG-Streamer installer will be updated and make its way back into KIAUH.
A big thanks to [KwadFan](https://github.com/KwadFan) for writing the crowsnest implementation.
@@ -115,7 +156,7 @@ membership for example caused issues when installing mjpg-streamer while not usi
Other issues could occur when trying to flash an MCU on Debian or Ubuntu distributions where a user might not be part
of the dialout group by default. A check for the tty group is also done. The tty group is needed for setting
up a linux MCU (currently not yet supported by KIAUH).
* There is an issue when trying to install Mainsail or Fluidd on Ubuntu 21.10. Permissions on that distro seem to have seen a rework
* There is an issue when trying to install Mainsail or Fluidd on Ubuntu 21.10. Permissions on that distro seem to have seen a rework
in comparison to 20.04 and users will be greeted with an "Error 403 - Permission denied" message after installing one of Klippers webinterfaces.
I still have to figure out a viable solution for that.

View File

@@ -9,7 +9,13 @@
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
import io
import sys
from kiauh.main import main
# ensure that all output is utf-8 encoded
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
if __name__ == "__main__":
main()

239
kiauh.sh
View File

@@ -10,99 +10,168 @@
#=======================================================================#
set -e
clear
clear -x
function main() {
local python_command
# make sure we have the correct permissions while running the script
umask 022
### sourcing all additional scripts
KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")"
for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done
for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done
#===================================================#
#=================== UPDATE KIAUH ==================#
#===================================================#
function update_kiauh() {
status_msg "Updating KIAUH ..."
cd "${KIAUH_SRCDIR}"
git reset --hard && git pull
ok_msg "Update complete! Please restart KIAUH."
exit 0
}
#===================================================#
#=================== KIAUH STATUS ==================#
#===================================================#
function kiauh_update_avail() {
[[ ! -d "${KIAUH_SRCDIR}/.git" ]] && return
local origin head
cd "${KIAUH_SRCDIR}"
### abort if not on master branch
! git branch -a | grep -q "\* master" && return
### compare commit hash
git fetch -q
origin=$(git rev-parse --short=8 origin/master)
head=$(git rev-parse --short=8 HEAD)
if [[ ${origin} != "${head}" ]]; then
echo "true"
fi
}
function save_startup_version() {
local launch_version
echo "${1}"
sed -i "/^version_to_launch=/d" "${INI_FILE}"
sed -i '$a'"version_to_launch=${1}" "${INI_FILE}"
}
function kiauh_update_dialog() {
[[ ! $(kiauh_update_avail) == "true" ]] && return
top_border
echo -e "|${green} New KIAUH update available! ${white}|"
hr
echo -e "|${green} View Changelog: https://git.io/JnmlX ${white}|"
blank_line
echo -e "|${yellow} It is recommended to keep KIAUH up to date. Updates ${white}|"
echo -e "|${yellow} usually contain bugfixes, important changes or new ${white}|"
echo -e "|${yellow} features. Please consider updating! ${white}|"
bottom_border
local yn
read -p "${cyan}###### Do you want to update now? (Y/n):${white} " yn
while true; do
case "${yn}" in
Y|y|Yes|yes|"")
do_action "update_kiauh"
break;;
N|n|No|no)
break;;
*)
deny_action "kiauh_update_dialog";;
esac
done
}
function launch_kiauh_v5() {
main_menu
}
function launch_kiauh_v6() {
local entrypoint
if command -v python3 &>/dev/null; then
python_command="python3"
elif command -v python &>/dev/null; then
python_command="python"
else
echo "Python is not installed. Please install Python and try again."
if ! command -v python3 &>/dev/null || [[ $(python3 -V | cut -d " " -f2 | cut -d "." -f2) -lt 8 ]]; then
echo "Python 3.8 or higher is not installed!"
echo "Please install Python 3.8 or higher and try again."
exit 1
fi
entrypoint=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
${python_command} "${entrypoint}/kiauh.py"
export PYTHONPATH="${entrypoint}"
clear -x
python3 "${entrypoint}/kiauh.py"
}
main
function main() {
read_kiauh_ini "${FUNCNAME[0]}"
#### sourcing all additional scripts
#KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")"
#for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done
#for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done
#
##===================================================#
##=================== UPDATE KIAUH ==================#
##===================================================#
#
#function update_kiauh() {
# status_msg "Updating KIAUH ..."
#
# cd "${KIAUH_SRCDIR}"
# git reset --hard && git pull
#
# ok_msg "Update complete! Please restart KIAUH."
# exit 0
#}
#
##===================================================#
##=================== KIAUH STATUS ==================#
##===================================================#
#
#function kiauh_update_avail() {
# [[ ! -d "${KIAUH_SRCDIR}/.git" ]] && return
# local origin head
#
# cd "${KIAUH_SRCDIR}"
#
# ### abort if not on master branch
# ! git branch -a | grep -q "\* master" && return
#
# ### compare commit hash
# git fetch -q
# origin=$(git rev-parse --short=8 origin/master)
# head=$(git rev-parse --short=8 HEAD)
#
# if [[ ${origin} != "${head}" ]]; then
# echo "true"
# fi
#}
#
#function kiauh_update_dialog() {
# [[ ! $(kiauh_update_avail) == "true" ]] && return
# top_border
# echo -e "|${green} New KIAUH update available! ${white}|"
# hr
# echo -e "|${green} View Changelog: https://git.io/JnmlX ${white}|"
# blank_line
# echo -e "|${yellow} It is recommended to keep KIAUH up to date. Updates ${white}|"
# echo -e "|${yellow} usually contain bugfixes, important changes or new ${white}|"
# echo -e "|${yellow} features. Please consider updating! ${white}|"
# bottom_border
#
# local yn
# read -p "${cyan}###### Do you want to update now? (Y/n):${white} " yn
# while true; do
# case "${yn}" in
# Y|y|Yes|yes|"")
# do_action "update_kiauh"
# break;;
# N|n|No|no)
# break;;
# *)
# deny_action "kiauh_update_dialog";;
# esac
# done
#}
#
#check_euid
#init_logfile
#set_globals
#kiauh_update_dialog
#main_menu
if [[ ${version_to_launch} -eq 5 ]]; then
launch_kiauh_v5
elif [[ ${version_to_launch} -eq 6 ]]; then
launch_kiauh_v6
else
top_border
echo -e "| ${green}KIAUH v6.0.0-alpha1 is available now!${white} |"
hr
echo -e "| View Changelog: ${magenta}https://git.io/JnmlX${white} |"
blank_line
echo -e "| KIAUH v6 was completely rewritten from the ground up. |"
echo -e "| It's based on Python 3.8 and has many improvements. |"
blank_line
echo -e "| ${yellow}NOTE: Version 6 is still in alpha, so bugs may occur!${white} |"
echo -e "| ${yellow}Yet, your feedback and bug reports are very much${white} |"
echo -e "| ${yellow}appreciated and will help finalize the release.${white} |"
hr
echo -e "| Would you like to try out KIAUH v6? |"
echo -e "| 1) Yes |"
echo -e "| 2) No |"
echo -e "| 3) Yes, remember my choice for next time |"
echo -e "| 4) No, remember my choice for next time |"
quit_footer
while true; do
read -p "${cyan}###### Select action:${white} " -e input
case "${input}" in
1)
launch_kiauh_v6
break;;
2)
launch_kiauh_v5
break;;
3)
save_startup_version 6
launch_kiauh_v6
break;;
4)
save_startup_version 5
launch_kiauh_v5
break;;
Q|q)
echo -e "${green}###### Happy printing! ######${white}"; echo
exit 0;;
*)
error_msg "Invalid Input!\n";;
esac
done && input=""
fi
}
check_if_ratos
check_euid
init_logfile
set_globals
kiauh_update_dialog
read_kiauh_ini
init_ini
main

View File

@@ -10,7 +10,7 @@
from pathlib import Path
from core.backup_manager import BACKUP_ROOT_DIR
from utils.constants import SYSTEMD
from core.constants import SYSTEMD
# repo
CROWSNEST_REPO = "https://github.com/mainsail-crew/crowsnest.git"

View File

@@ -27,24 +27,24 @@ from components.crowsnest import (
)
from components.klipper.klipper import Klipper
from core.backup_manager.backup_manager import BackupManager
from core.instance_manager.instance_manager import InstanceManager
from core.constants import CURRENT_USER
from core.logger import DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings
from core.types import ComponentStatus
from utils.common import (
check_install_dependencies,
get_install_status,
)
from utils.constants import CURRENT_USER
from utils.git_utils import (
git_clone_wrapper,
git_pull_wrapper,
)
from utils.input_utils import get_confirm
from utils.logger import DialogType, Logger
from utils.instance_utils import get_instances
from utils.sys_utils import (
cmd_sysctl_service,
parse_packages_from_file,
)
from utils.types import ComponentStatus
def install_crowsnest() -> None:
@@ -52,11 +52,10 @@ def install_crowsnest() -> None:
git_clone_wrapper(CROWSNEST_REPO, CROWSNEST_DIR, "master")
# Step 2: Install dependencies
check_install_dependencies(["make"])
check_install_dependencies({"make"})
# Step 3: Check for Multi Instance
im = InstanceManager(Klipper)
instances: List[Klipper] = im.instances
instances: List[Klipper] = get_instances(Klipper)
if len(instances) > 1:
print_multi_instance_warning(instances)
@@ -95,7 +94,7 @@ def print_multi_instance_warning(instances: List[Klipper]) -> None:
"this instance to set up your 'crowsnest.conf' and steering it's service.",
"\n\n",
"The following instances were found:",
*[f"{instance.data_dir_name}" for instance in instances],
*[f"{instance.data_dir.name}" for instance in instances],
],
)
@@ -139,7 +138,7 @@ def update_crowsnest() -> None:
git_pull_wrapper(CROWSNEST_REPO, CROWSNEST_DIR)
deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT)
check_install_dependencies(deps)
check_install_dependencies({*deps})
cmd_sysctl_service(CROWSNEST_SERVICE_NAME, "restart")

View File

@@ -6,9 +6,11 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from dataclasses import dataclass
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from subprocess import CalledProcessError, run
from subprocess import CalledProcessError
from components.klipper import (
KLIPPER_CFG_NAME,
@@ -21,29 +23,38 @@ from components.klipper import (
KLIPPER_SERVICE_TEMPLATE,
KLIPPER_UDS_NAME,
)
from core.constants import CURRENT_USER
from core.instance_manager.base_instance import BaseInstance
from utils.logger import Logger
from core.logger import Logger
from utils.fs_utils import create_folders, get_data_dir
from utils.sys_utils import get_service_file_path
# noinspection PyMethodMayBeStatic
@dataclass
class Klipper(BaseInstance):
@dataclass(repr=True)
class Klipper:
suffix: str
base: BaseInstance = field(init=False, repr=False)
service_file_path: Path = field(init=False)
log_file_name: str = KLIPPER_LOG_NAME
klipper_dir: Path = KLIPPER_DIR
env_dir: Path = KLIPPER_ENV_DIR
cfg_file: Path = None
log: Path = None
serial: Path = None
uds: Path = None
data_dir: Path = field(init=False)
cfg_file: Path = field(init=False)
env_file: Path = field(init=False)
serial: Path = field(init=False)
uds: Path = field(init=False)
def __init__(self, suffix: str = "") -> None:
super().__init__(instance_type=self, suffix=suffix)
def __post_init__(self):
self.base: BaseInstance = BaseInstance(Klipper, self.suffix)
self.base.log_file_name = self.log_file_name
def __post_init__(self) -> None:
super().__post_init__()
self.cfg_file = self.cfg_dir.joinpath(KLIPPER_CFG_NAME)
self.log = self.log_dir.joinpath(KLIPPER_LOG_NAME)
self.serial = self.comms_dir.joinpath(KLIPPER_SERIAL_NAME)
self.uds = self.comms_dir.joinpath(KLIPPER_UDS_NAME)
self.service_file_path: Path = get_service_file_path(Klipper, self.suffix)
self.data_dir: Path = get_data_dir(Klipper, self.suffix)
self.cfg_file: Path = self.base.cfg_dir.joinpath(KLIPPER_CFG_NAME)
self.env_file: Path = self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME)
self.serial: Path = self.base.comms_dir.joinpath(KLIPPER_SERIAL_NAME)
self.uds: Path = self.base.comms_dir.joinpath(KLIPPER_UDS_NAME)
def create(self) -> None:
from utils.sys_utils import create_env_file, create_service_file
@@ -51,15 +62,15 @@ class Klipper(BaseInstance):
Logger.print_status("Creating new Klipper Instance ...")
try:
self.create_folders()
create_folders(self.base.base_folders)
create_service_file(
name=self.get_service_file_name(extension=True),
name=self.service_file_path.name,
content=self._prep_service_file_content(),
)
create_env_file(
path=self.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME),
path=self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME),
content=self._prep_env_file_content(),
)
@@ -70,21 +81,6 @@ class Klipper(BaseInstance):
Logger.print_error(f"Error creating env file: {e}")
raise
def delete(self) -> None:
service_file = self.get_service_file_name(extension=True)
service_file_path = self.get_service_file_path()
Logger.print_status(f"Removing Klipper Instance: {service_file}")
try:
command = ["sudo", "rm", "-f", service_file_path]
run(command, check=True)
self.delete_logfiles(KLIPPER_LOG_NAME)
Logger.print_ok("Instance successfully removed!")
except CalledProcessError as e:
Logger.print_error(f"Error removing instance: {e}")
raise
def _prep_service_file_content(self) -> str:
template = KLIPPER_SERVICE_TEMPLATE
@@ -97,7 +93,7 @@ class Klipper(BaseInstance):
service_content = template_content.replace(
"%USER%",
self.user,
CURRENT_USER,
)
service_content = service_content.replace(
"%KLIPPER_DIR%",
@@ -109,7 +105,7 @@ class Klipper(BaseInstance):
)
service_content = service_content.replace(
"%ENV_FILE%",
self.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME).as_posix(),
self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME).as_posix(),
)
return service_content
@@ -128,19 +124,19 @@ class Klipper(BaseInstance):
)
env_file_content = env_file_content.replace(
"%CFG%",
f"{self.cfg_dir}/{KLIPPER_CFG_NAME}",
f"{self.base.cfg_dir}/{KLIPPER_CFG_NAME}",
)
env_file_content = env_file_content.replace(
"%SERIAL%",
self.serial.as_posix(),
self.serial.as_posix() if self.serial else "",
)
env_file_content = env_file_content.replace(
"%LOG%",
self.log.as_posix(),
self.base.log_dir.joinpath(self.log_file_name).as_posix(),
)
env_file_content = env_file_content.replace(
"%UDS%",
self.uds.as_posix(),
self.uds.as_posix() if self.uds else "",
)
return env_file_content

View File

@@ -11,14 +11,14 @@ import textwrap
from enum import Enum, unique
from typing import List
from core.instance_manager.base_instance import BaseInstance
from core.menus.base_menu import print_back_footer
from utils.constants import (
from core.constants import (
COLOR_CYAN,
COLOR_GREEN,
COLOR_YELLOW,
RESET_FORMAT,
)
from core.menus.base_menu import print_back_footer
from utils.instance_type import InstanceType
@unique
@@ -28,13 +28,13 @@ class DisplayType(Enum):
def print_instance_overview(
instances: List[BaseInstance],
instances: List[InstanceType],
display_type: DisplayType = DisplayType.SERVICE_NAME,
show_headline=True,
show_index=False,
start_index=0,
show_select_all=False,
):
) -> None:
dialog = "╔═══════════════════════════════════════════════════════╗\n"
if show_headline:
d_type = (
@@ -53,7 +53,7 @@ def print_instance_overview(
for i, s in enumerate(instances):
if display_type is DisplayType.SERVICE_NAME:
name = s.get_service_file_name()
name = s.service_file_path.stem
else:
name = s.data_dir
line = f"{COLOR_CYAN}{f'{i + start_index})' if show_index else ''} {name}{RESET_FORMAT}"
@@ -64,7 +64,7 @@ def print_instance_overview(
print_back_footer()
def print_select_instance_count_dialog():
def print_select_instance_count_dialog() -> None:
line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}"
line2 = f"{COLOR_YELLOW}Setting up too many instances may crash your system.{RESET_FORMAT}"
dialog = textwrap.dedent(
@@ -84,7 +84,7 @@ def print_select_instance_count_dialog():
print_back_footer()
def print_select_custom_name_dialog():
def print_select_custom_name_dialog() -> None:
line1 = f"{COLOR_YELLOW}INFO:{RESET_FORMAT}"
line2 = f"{COLOR_YELLOW}Only alphanumeric characters are allowed!{RESET_FORMAT}"
dialog = textwrap.dedent(

View File

@@ -6,17 +6,19 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
from typing import List, Union
from typing import List
from components.klipper import KLIPPER_DIR, KLIPPER_ENV_DIR
from components.klipper.klipper import Klipper
from components.klipper.klipper_dialogs import print_instance_overview
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from utils.fs_utils import run_remove_routines
from utils.input_utils import get_selection_input
from utils.logger import Logger
from utils.sys_utils import cmd_sysctl_manage
from utils.instance_utils import get_instances
from utils.sys_utils import unit_file_exists
def run_klipper_removal(
@@ -24,17 +26,17 @@ def run_klipper_removal(
remove_dir: bool,
remove_env: bool,
) -> None:
im = InstanceManager(Klipper)
klipper_instances: List[Klipper] = get_instances(Klipper)
if remove_service:
Logger.print_status("Removing Klipper instances ...")
if im.instances:
instances_to_remove = select_instances_to_remove(im.instances)
remove_instances(im, instances_to_remove)
if klipper_instances:
instances_to_remove = select_instances_to_remove(klipper_instances)
remove_instances(instances_to_remove)
else:
Logger.print_info("No Klipper Services installed! Skipped ...")
if (remove_dir or remove_env) and im.instances:
if (remove_dir or remove_env) and unit_file_exists("klipper", suffix="service"):
Logger.print_info("There are still other Klipper services installed:")
Logger.print_info(f"'{KLIPPER_DIR}' was not removed.", prefix=False)
Logger.print_info(f"'{KLIPPER_ENV_DIR}' was not removed.", prefix=False)
@@ -47,9 +49,7 @@ def run_klipper_removal(
run_remove_routines(KLIPPER_ENV_DIR)
def select_instances_to_remove(
instances: List[Klipper],
) -> Union[List[Klipper], None]:
def select_instances_to_remove(instances: List[Klipper]) -> List[Klipper] | None:
start_index = 1
options = [str(i + start_index) for i in range(len(instances))]
options.extend(["a", "b"])
@@ -75,30 +75,21 @@ def select_instances_to_remove(
def remove_instances(
instance_manager: InstanceManager,
instance_list: List[Klipper],
instance_list: List[Klipper] | None,
) -> None:
if not instance_list:
return
for instance in instance_list:
Logger.print_status(f"Removing instance {instance.get_service_file_name()} ...")
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance()
cmd_sysctl_manage("daemon-reload")
Logger.print_status(f"Removing instance {instance.service_file_path.stem} ...")
InstanceManager.remove(instance)
delete_klipper_env_file(instance)
def delete_klipper_logs(instances: List[Klipper]) -> None:
all_logfiles = []
for instance in instances:
all_logfiles = list(instance.log_dir.glob("klippy.log*"))
if not all_logfiles:
Logger.print_info("No Klipper logs found. Skipped ...")
def delete_klipper_env_file(instance: Klipper):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
for log in all_logfiles:
Logger.print_status(f"Remove '{log}'")
run_remove_routines(log)
run_remove_routines(instance.env_file)

View File

@@ -35,13 +35,15 @@ from components.webui_client.client_utils import (
get_existing_clients,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import get_confirm
from utils.logger import DialogType, Logger
from utils.instance_utils import get_instances
from utils.sys_utils import (
cmd_sysctl_manage,
cmd_sysctl_service,
create_python_venv,
install_python_requirements,
parse_packages_from_file,
@@ -51,8 +53,8 @@ from utils.sys_utils import (
def install_klipper() -> None:
Logger.print_status("Installing Klipper ...")
klipper_list: List[Klipper] = InstanceManager(Klipper).instances
moonraker_list: List[Moonraker] = InstanceManager(Moonraker).instances
klipper_list: List[Klipper] = get_instances(Klipper)
moonraker_list: List[Moonraker] = get_instances(Moonraker)
match_moonraker: bool = False
# if there are more moonraker instances than klipper instances, ask the user to
@@ -94,7 +96,7 @@ def install_klipper() -> None:
def run_klipper_setup(
klipper_list: List[Klipper], name_dict: Dict[int, str], example_cfg: bool
klipper_list: List[Klipper], name_dict: Dict[int, str], create_example_cfg: bool
) -> None:
if not klipper_list:
setup_klipper_prerequesites()
@@ -104,7 +106,16 @@ def run_klipper_setup(
if name_dict[i] in [n.suffix for n in klipper_list]:
continue
create_klipper_instance(name_dict[i], example_cfg)
instance = Klipper(suffix=name_dict[i])
instance.create()
cmd_sysctl_service(instance.service_file_path.name, "enable")
if create_example_cfg:
# if a client-config is installed, include it in the new example cfg
clients = get_existing_clients()
create_example_printer_cfg(instance, clients)
cmd_sysctl_service(instance.service_file_path.name, "start")
cmd_sysctl_manage("daemon-reload")
@@ -119,7 +130,7 @@ def handle_instance_names(
install_count: int, name_dict: Dict[int, str], custom_names: bool
) -> None:
for i in range(install_count): # 3
key = max(name_dict.keys()) + 1
key: int = len(name_dict.keys()) + 1
if custom_names:
assign_custom_name(key, name_dict)
else:
@@ -129,10 +140,10 @@ def handle_instance_names(
def get_install_count_and_name_dict(
klipper_list: List[Klipper], moonraker_list: List[Moonraker]
) -> Tuple[int, Dict[int, str]]:
install_count: int | None
if len(moonraker_list) > len(klipper_list):
install_count = len(moonraker_list)
name_dict = {i: moonraker.suffix for i, moonraker in enumerate(moonraker_list)}
else:
install_count = get_install_count()
name_dict = {i: klipper.suffix for i, klipper in enumerate(klipper_list)}
@@ -154,8 +165,8 @@ def setup_klipper_prerequesites() -> None:
# install klipper dependencies and create python virtualenv
try:
install_klipper_packages()
create_python_venv(KLIPPER_ENV_DIR)
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
if create_python_venv(KLIPPER_ENV_DIR):
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
except Exception:
Logger.print_error("Error during installation of Klipper requirements!")
raise
@@ -169,7 +180,7 @@ def install_klipper_packages() -> None:
if Path("/boot/dietpi/.version").exists():
packages.append("dbus")
check_install_dependencies(packages)
check_install_dependencies({*packages})
def update_klipper() -> None:
@@ -189,8 +200,8 @@ def update_klipper() -> None:
if settings.kiauh.backup_before_update:
backup_klipper_dir()
instance_manager = InstanceManager(Klipper)
instance_manager.stop_all_instance()
instances = get_instances(Klipper)
InstanceManager.stop_all(instances)
git_pull_wrapper(repo=settings.klipper.repo_url, target_dir=KLIPPER_DIR)
@@ -199,29 +210,17 @@ def update_klipper() -> None:
# install possible new python dependencies
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
instance_manager.start_all_instance()
def create_klipper_instance(name: str, create_example_cfg: bool) -> None:
kl_im = InstanceManager(Klipper)
new_instance = Klipper(suffix=name)
kl_im.current_instance = new_instance
kl_im.create_instance()
kl_im.enable_instance()
if create_example_cfg:
# if a client-config is installed, include it in the new example cfg
clients = get_existing_clients()
create_example_printer_cfg(new_instance, clients)
kl_im.start_instance()
InstanceManager.start_all(instances)
def use_custom_names_or_go_back() -> bool | None:
print_select_custom_name_dialog()
return get_confirm(
_input: bool | None = get_confirm(
"Assign custom names?",
False,
allow_go_back=True,
)
return _input
def display_moonraker_info(moonraker_list: List[Moonraker]) -> bool:
@@ -230,12 +229,11 @@ def display_moonraker_info(moonraker_list: List[Moonraker]) -> bool:
DialogType.INFO,
[
"Existing Moonraker instances detected:",
*[f"{m.get_service_file_name()}" for m in moonraker_list],
*[f"{m.service_file_path.stem}" for m in moonraker_list],
"\n\n",
"The following Klipper instances will be installed:",
*[f"● klipper-{m.suffix}" for m in moonraker_list],
],
padding_top=0,
padding_bottom=0,
)
return get_confirm("Proceed with installation?")
_input: bool = get_confirm("Proceed with installation?")
return _input

View File

@@ -30,26 +30,28 @@ from components.webui_client.client_config.client_config_setup import (
create_client_config_symlink,
)
from core.backup_manager.backup_manager import BackupManager
from core.instance_manager.instance_manager import InstanceManager
from core.constants import CURRENT_USER
from core.instance_manager.base_instance import SUFFIX_BLACKLIST
from core.logger import DialogType, Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from core.types import ComponentStatus
from utils.common import get_install_status
from utils.constants import CURRENT_USER
from utils.input_utils import get_confirm, get_number_input, get_string_input
from utils.logger import DialogType, Logger
from utils.instance_utils import get_instances
from utils.sys_utils import cmd_sysctl_service
from utils.types import ComponentStatus
def get_klipper_status() -> ComponentStatus:
return get_install_status(KLIPPER_DIR, KLIPPER_ENV_DIR, Klipper)
def add_to_existing() -> bool:
kl_instances = InstanceManager(Klipper).instances
def add_to_existing() -> bool | None:
kl_instances: List[Klipper] = get_instances(Klipper)
print_instance_overview(kl_instances)
return get_confirm("Add new instances?", allow_go_back=True)
_input: bool | None = get_confirm("Add new instances?", allow_go_back=True)
return _input
def get_install_count() -> int | None:
@@ -59,19 +61,20 @@ def get_install_count() -> int | None:
user selected to go back, otherwise an integer greater or equal than 1 |
:return: Integer >= 1 or None
"""
kl_instances = InstanceManager(Klipper).instances
kl_instances = get_instances(Klipper)
print_select_instance_count_dialog()
question = (
f"Number of"
f"{' additional' if len(kl_instances) > 0 else ''} "
f"Klipper instances to set up"
)
return get_number_input(question, 1, default=1, allow_go_back=True)
_input: int | None = get_number_input(question, 1, default=1, allow_go_back=True)
return _input
def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None:
existing_names = []
existing_names.extend(Klipper.blacklist())
existing_names.extend(SUFFIX_BLACKLIST)
existing_names.extend(name_dict[n] for n in name_dict)
pattern = r"^[a-zA-Z0-9]+$"
@@ -79,7 +82,7 @@ def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None:
name_dict[key] = get_string_input(question, exclude=existing_names, regex=pattern)
def check_user_groups():
def check_user_groups() -> None:
user_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()]
missing_groups = [g for g in ["tty", "dialout"] if g not in user_groups]
@@ -100,7 +103,6 @@ def check_user_groups():
"INFO:",
"Relog required for group assignments to take effect!",
],
padding_bottom=0,
)
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
@@ -158,7 +160,7 @@ def handle_disruptive_system_packages() -> None:
def create_example_printer_cfg(
instance: Klipper, clients: List[BaseWebClient] | None = None
) -> None:
Logger.print_status(f"Creating example printer.cfg in '{instance.cfg_dir}'")
Logger.print_status(f"Creating example printer.cfg in '{instance.base.cfg_dir}'")
if instance.cfg_file.is_file():
Logger.print_info(f"'{instance.cfg_file}' already exists.")
return
@@ -172,8 +174,8 @@ def create_example_printer_cfg(
return
scp = SimpleConfigParser()
scp.read(target)
scp.set("virtual_sdcard", "path", str(instance.gcodes_dir))
scp.read_file(target)
scp.set_option("virtual_sdcard", "path", str(instance.base.gcodes_dir))
# include existing client configs in the example config
if clients is not None and len(clients) > 0:
@@ -183,9 +185,9 @@ def create_example_printer_cfg(
scp.add_section(section=section)
create_client_config_symlink(client_config, [instance])
scp.write(target)
scp.write_file(target)
Logger.print_ok(f"Example printer.cfg created in '{instance.cfg_dir}'")
Logger.print_ok(f"Example printer.cfg created in '{instance.base.cfg_dir}'")
def backup_klipper_dir() -> None:

View File

@@ -6,41 +6,40 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.klipper import klipper_remove
from core.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
from core.menus import FooterType, Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
# noinspection PyUnusedLocal
class KlipperRemoveMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.footer_type = FooterType.BACK
self.remove_klipper_service = False
self.remove_klipper_dir = False
self.remove_klipper_env = False
self.selection_state = False
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.remove_menu import RemoveMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else RemoveMenu
)
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
def set_options(self) -> None:
self.options = {
"a": Option(method=self.toggle_all, menu=False),
"1": Option(method=self.toggle_remove_klipper_service, menu=False),
"2": Option(method=self.toggle_remove_klipper_dir, menu=False),
"3": Option(method=self.toggle_remove_klipper_env, menu=False),
"c": Option(method=self.run_removal_process, menu=False),
"a": Option(method=self.toggle_all),
"1": Option(method=self.toggle_remove_klipper_service),
"2": Option(method=self.toggle_remove_klipper_dir),
"3": Option(method=self.toggle_remove_klipper_env),
"c": Option(method=self.run_removal_process),
}
def print_menu(self) -> None:
@@ -73,10 +72,10 @@ class KlipperRemoveMenu(BaseMenu):
print(menu, end="")
def toggle_all(self, **kwargs) -> None:
self.remove_klipper_service = not self.remove_klipper_service
self.remove_klipper_dir = not self.remove_klipper_dir
self.remove_klipper_env = not self.remove_klipper_env
self.selection_state = not self.selection_state
self.remove_klipper_service = self.selection_state
self.remove_klipper_dir = self.selection_state
self.remove_klipper_env = self.selection_state
def toggle_remove_klipper_service(self, **kwargs) -> None:
self.remove_klipper_service = not self.remove_klipper_service

View File

@@ -6,8 +6,16 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from subprocess import PIPE, STDOUT, CalledProcessError, Popen, check_output, run
import re
from subprocess import (
DEVNULL,
PIPE,
STDOUT,
CalledProcessError,
Popen,
check_output,
run,
)
from typing import List
from components.klipper import KLIPPER_DIR
@@ -18,28 +26,32 @@ from components.klipper_firmware.flash_options import (
FlashOptions,
)
from core.instance_manager.instance_manager import InstanceManager
from utils.logger import Logger
from core.logger import Logger
from utils.instance_utils import get_instances
from utils.sys_utils import log_process
def find_firmware_file() -> bool:
target = KLIPPER_DIR.joinpath("out")
target_exists = target.exists()
target_exists: bool = target.exists()
f1 = "klipper.elf.hex"
f2 = "klipper.elf"
f3 = "klipper.bin"
fw_file_exists = (
target.joinpath(f1).exists() and target.joinpath(f2).exists()
) or target.joinpath(f3).exists()
f4 = "klipper.uf2"
fw_file_exists: bool = (
(target.joinpath(f1).exists() and target.joinpath(f2).exists())
or target.joinpath(f3).exists()
or target.joinpath(f4).exists()
)
return target_exists and fw_file_exists
def find_usb_device_by_id() -> List[str]:
try:
command = "find /dev/serial/by-id/* 2>/dev/null"
output = check_output(command, shell=True, text=True)
command = "find /dev/serial/by-id/*"
output = check_output(command, shell=True, text=True, stderr=DEVNULL)
return output.splitlines()
except CalledProcessError as e:
Logger.print_error("Unable to find a USB device!")
@@ -49,9 +61,14 @@ def find_usb_device_by_id() -> List[str]:
def find_uart_device() -> List[str]:
try:
command = '"find /dev -maxdepth 1 -regextype posix-extended -regex "^\/dev\/tty(AMA0|S0)$" 2>/dev/null"'
output = check_output(command, shell=True, text=True)
return output.splitlines()
cmd = "find /dev -maxdepth 1"
output = check_output(cmd, shell=True, text=True, stderr=DEVNULL)
device_list = []
if output:
pattern = r"^/dev/tty(AMA0|S0)$"
devices = output.splitlines()
device_list = [d for d in devices if re.search(pattern, d)]
return device_list
except CalledProcessError as e:
Logger.print_error("Unable to find a UART device!")
Logger.print_error(e, prefix=False)
@@ -60,25 +77,45 @@ def find_uart_device() -> List[str]:
def find_usb_dfu_device() -> List[str]:
try:
command = '"lsusb | grep "DFU" | cut -d " " -f 6 2>/dev/null"'
output = check_output(command, shell=True, text=True)
return output.splitlines()
output = check_output("lsusb", shell=True, text=True, stderr=DEVNULL)
device_list = []
if output:
devices = output.splitlines()
device_list = [d.split(" ")[5] for d in devices if "DFU" in d]
return device_list
except CalledProcessError as e:
Logger.print_error("Unable to find a USB DFU device!")
Logger.print_error(e, prefix=False)
return []
def find_usb_rp2_boot_device() -> List[str]:
try:
output = check_output("lsusb", shell=True, text=True, stderr=DEVNULL)
device_list = []
if output:
devices = output.splitlines()
device_list = [d.split(" ")[5] for d in devices if "RP2 Boot" in d]
return device_list
except CalledProcessError as e:
Logger.print_error("Unable to find a USB RP2 Boot device!")
Logger.print_error(e, prefix=False)
return []
def get_sd_flash_board_list() -> List[str]:
if not KLIPPER_DIR.exists() or not SD_FLASH_SCRIPT.exists():
return []
try:
cmd = f"{SD_FLASH_SCRIPT} -l"
blist = check_output(cmd, shell=True, text=True)
return blist.splitlines()[1:]
blist: List[str] = check_output(cmd, shell=True, text=True).splitlines()[1:]
return blist
except CalledProcessError as e:
Logger.print_error(f"An unexpected error occured:\n{e}")
return []
def start_flash_process(flash_options: FlashOptions) -> None:
@@ -116,13 +153,13 @@ def start_flash_process(flash_options: FlashOptions) -> None:
else:
raise Exception("Invalid value for flash_method!")
instance_manager = InstanceManager(Klipper)
instance_manager.stop_all_instance()
instances = get_instances(Klipper)
InstanceManager.stop_all(instances)
process = Popen(cmd, cwd=KLIPPER_DIR, stdout=PIPE, stderr=STDOUT, text=True)
log_process(process)
instance_manager.start_all_instance()
InstanceManager.start_all(instances)
rc = process.returncode
if rc != 0:

View File

@@ -26,6 +26,7 @@ class FlashCommand(Enum):
class ConnectionType(Enum):
USB = "USB"
USB_DFU = "USB (DFU)"
USB_RP2040 = "USB (RP2040)"
UART = "UART"
@@ -45,7 +46,7 @@ class FlashOptions:
return cls._instance
@classmethod
def destroy(cls):
def destroy(cls) -> None:
cls._instance = None
@property

View File

@@ -6,9 +6,10 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import List, Set, Type
from components.klipper import KLIPPER_DIR
from components.klipper_firmware.firmware_utils import (
@@ -16,10 +17,10 @@ from components.klipper_firmware.firmware_utils import (
run_make_clean,
run_make_menuconfig,
)
from core.constants import COLOR_CYAN, COLOR_GREEN, COLOR_RED, RESET_FORMAT
from core.logger import Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_GREEN, COLOR_RED, RESET_FORMAT
from utils.logger import Logger
from utils.sys_utils import (
check_package_install,
install_system_packages,
@@ -30,26 +31,26 @@ from utils.sys_utils import (
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperBuildFirmwareMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.deps = ["build-essential", "dpkg-dev", "make"]
self.missing_deps = check_package_install(self.deps)
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.deps: Set[str] = {"build-essential", "dpkg-dev", "make"}
self.missing_deps: List[str] = check_package_install(self.deps)
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.advanced_menu import AdvancedMenu
self.previous_menu: Type[BaseMenu] = (
self.previous_menu = (
previous_menu if previous_menu is not None else AdvancedMenu
)
def set_options(self) -> None:
if len(self.missing_deps) == 0:
self.input_label_txt = "Press ENTER to continue"
self.default_option = Option(method=self.start_build_process, menu=False)
self.default_option = Option(method=self.start_build_process)
else:
self.input_label_txt = "Press ENTER to install dependencies"
self.default_option = Option(method=self.install_missing_deps, menu=False)
self.default_option = Option(method=self.install_missing_deps)
def print_menu(self) -> None:
header = " [ Build Firmware Menu ] "
@@ -80,6 +81,7 @@ class KlipperBuildFirmwareMenu(BaseMenu):
line = f"{COLOR_RED}Dependencies are missing!{RESET_FORMAT}"
menu += f"{line:<62}\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
print(menu, end="")
@@ -109,4 +111,5 @@ class KlipperBuildFirmwareMenu(BaseMenu):
Logger.print_error("Building Klipper Firmware failed!")
finally:
self.previous_menu().run()
if self.previous_menu is not None:
self.previous_menu().run()

View File

@@ -6,31 +6,33 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.klipper_firmware.flash_options import FlashMethod, FlashOptions
from core.constants import COLOR_RED, RESET_FORMAT
from core.menus import FooterType, Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_RED, RESET_FORMAT
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperNoFirmwareErrorMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.flash_options = FlashOptions()
self.footer_type = FooterType.BLANK
self.input_label_txt = "Press ENTER to go back to [Advanced Menu]"
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = previous_menu
def set_options(self) -> None:
self.default_option = Option(self.go_back, False)
self.default_option = Option(method=self.go_back)
def print_menu(self) -> None:
header = "!!! NO FIRMWARE FILE FOUND !!!"
@@ -67,17 +69,17 @@ class KlipperNoFirmwareErrorMenu(BaseMenu):
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperNoBoardTypesErrorMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.footer_type = FooterType.BLANK
self.input_label_txt = "Press ENTER to go back to [Main Menu]"
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = previous_menu
def set_options(self) -> None:
self.default_option = Option(self.go_back, False)
self.default_option = Option(method=self.go_back)
def print_menu(self) -> None:
header = "!!! ERROR GETTING BOARD LIST !!!"

View File

@@ -6,25 +6,27 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
import textwrap
from typing import Optional, Type
from __future__ import annotations
import textwrap
from typing import Type
from core.constants import COLOR_CYAN, COLOR_YELLOW, RESET_FORMAT
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_YELLOW, RESET_FORMAT
# noinspection DuplicatedCode
class KlipperFlashMethodHelpMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from components.klipper_firmware.menus.klipper_flash_menu import (
KlipperFlashMethodMenu,
)
self.previous_menu: Type[BaseMenu] = (
self.previous_menu = (
previous_menu if previous_menu is not None else KlipperFlashMethodMenu
)
@@ -73,16 +75,16 @@ class KlipperFlashMethodHelpMenu(BaseMenu):
# noinspection DuplicatedCode
class KlipperFlashCommandHelpMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from components.klipper_firmware.menus.klipper_flash_menu import (
KlipperFlashCommandMenu,
)
self.previous_menu: Type[BaseMenu] = (
self.previous_menu = (
previous_menu if previous_menu is not None else KlipperFlashCommandMenu
)
@@ -117,16 +119,16 @@ class KlipperFlashCommandHelpMenu(BaseMenu):
# noinspection DuplicatedCode
class KlipperMcuConnectionHelpMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from components.klipper_firmware.menus.klipper_flash_menu import (
KlipperSelectMcuConnectionMenu,
)
self.previous_menu: Type[BaseMenu] = (
self.previous_menu = (
previous_menu
if previous_menu is not None
else KlipperSelectMcuConnectionMenu
@@ -141,6 +143,8 @@ class KlipperMcuConnectionHelpMenu(BaseMenu):
count = 62 - len(color) - len(RESET_FORMAT)
subheader1 = f"{COLOR_CYAN}USB:{RESET_FORMAT}"
subheader2 = f"{COLOR_CYAN}UART:{RESET_FORMAT}"
subheader3 = f"{COLOR_CYAN}USB DFU:{RESET_FORMAT}"
subheader4 = f"{COLOR_CYAN}USB RP2040 Boot:{RESET_FORMAT}"
menu = textwrap.dedent(
f"""
╔═══════════════════════════════════════════════════════╗
@@ -162,6 +166,19 @@ class KlipperMcuConnectionHelpMenu(BaseMenu):
║ port your controller board is connected to when using ║
║ this connection method. ║
║ ║
{subheader3:<62}
║ Selecting USB DFU as the connection method will scan ║
║ the USB ports for connected controller boards in ║
║ STM32 DFU mode, which is usually done by holding down ║
║ the BOOT button or setting a special jumper on the ║
║ board before powering up. ║
║ ║
{subheader4:<62}
║ Selecting USB RP2 Boot as the connection method will ║
║ scan the USB ports for connected RP2040 controller ║
║ boards in Boot mode, which is usually done by holding ║
║ down the BOOT button before powering up. ║
║ ║
╟───────────────────────────────────────────────────────╢
"""
)[1:]

View File

@@ -6,16 +6,18 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
import time
from typing import Optional, Type
from typing import Type
from components.klipper_firmware.firmware_utils import (
find_firmware_file,
find_uart_device,
find_usb_device_by_id,
find_usb_dfu_device,
find_usb_rp2_boot_device,
get_sd_flash_board_list,
start_flash_process,
)
@@ -34,34 +36,34 @@ from components.klipper_firmware.menus.klipper_flash_help_menu import (
KlipperFlashMethodHelpMenu,
KlipperMcuConnectionHelpMenu,
)
from core.constants import COLOR_CYAN, COLOR_RED, COLOR_YELLOW, RESET_FORMAT
from core.logger import DialogType, Logger
from core.menus import FooterType, Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_RED, COLOR_YELLOW, RESET_FORMAT
from utils.input_utils import get_number_input
from utils.logger import DialogType, Logger
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperFlashMethodMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.help_menu = KlipperFlashMethodHelpMenu
self.input_label_txt = "Select flash method"
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.advanced_menu import AdvancedMenu
self.previous_menu: Type[BaseMenu] = (
self.previous_menu = (
previous_menu if previous_menu is not None else AdvancedMenu
)
def set_options(self) -> None:
self.options = {
"1": Option(self.select_regular, menu=False),
"2": Option(self.select_sdcard, menu=False),
"1": Option(self.select_regular),
"2": Option(self.select_sdcard),
}
def print_menu(self) -> None:
@@ -108,24 +110,24 @@ class KlipperFlashMethodMenu(BaseMenu):
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperFlashCommandMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.help_menu = KlipperFlashCommandHelpMenu
self.input_label_txt = "Select flash command"
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
self.previous_menu: Type[BaseMenu] = (
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = (
previous_menu if previous_menu is not None else KlipperFlashMethodMenu
)
def set_options(self) -> None:
self.options = {
"1": Option(self.select_flash, menu=False),
"2": Option(self.select_serialflash, menu=False),
"1": Option(self.select_flash),
"2": Option(self.select_serialflash),
}
self.default_option = Option(self.select_flash, menu=False)
self.default_option = Option(self.select_flash)
def print_menu(self) -> None:
menu = textwrap.dedent(
@@ -156,26 +158,27 @@ class KlipperFlashCommandMenu(BaseMenu):
# noinspection PyMethodMayBeStatic
class KlipperSelectMcuConnectionMenu(BaseMenu):
def __init__(
self, previous_menu: Optional[Type[BaseMenu]] = None, standalone: bool = False
self, previous_menu: Type[BaseMenu] | None = None, standalone: bool = False
):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.__standalone = standalone
self.help_menu = KlipperMcuConnectionHelpMenu
self.input_label_txt = "Select connection type"
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
self.previous_menu: Type[BaseMenu] = (
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = (
previous_menu if previous_menu is not None else KlipperFlashCommandMenu
)
def set_options(self) -> None:
self.options = {
"1": Option(method=self.select_usb, menu=False),
"2": Option(method=self.select_dfu, menu=False),
"3": Option(method=self.select_usb_dfu, menu=False),
"1": Option(method=self.select_usb),
"2": Option(method=self.select_dfu),
"3": Option(method=self.select_usb_dfu),
"4": Option(method=self.select_usb_rp2040),
}
def print_menu(self) -> None:
@@ -192,6 +195,7 @@ class KlipperSelectMcuConnectionMenu(BaseMenu):
║ 1) USB ║
║ 2) UART ║
║ 3) USB (DFU mode) ║
║ 4) USB (RP2040 mode) ║
╟───────────────────────────┬───────────────────────────╢
"""
)[1:]
@@ -209,6 +213,10 @@ class KlipperSelectMcuConnectionMenu(BaseMenu):
self.flash_options.connection_type = ConnectionType.USB_DFU
self.get_mcu_list()
def select_usb_rp2040(self, **kwargs):
self.flash_options.connection_type = ConnectionType.USB_RP2040
self.get_mcu_list()
def get_mcu_list(self, **kwargs):
conn_type = self.flash_options.connection_type
@@ -221,6 +229,9 @@ class KlipperSelectMcuConnectionMenu(BaseMenu):
elif conn_type is ConnectionType.USB_DFU:
Logger.print_status("Identifying MCU connected via USB in DFU mode ...")
self.flash_options.mcu_list = find_usb_dfu_device()
elif conn_type is ConnectionType.USB_RP2040:
Logger.print_status("Identifying MCU connected via USB in RP2 Boot mode ...")
self.flash_options.mcu_list = find_usb_rp2_boot_device()
if len(self.flash_options.mcu_list) < 1:
Logger.print_warn("No MCUs found!")
@@ -243,15 +254,15 @@ class KlipperSelectMcuConnectionMenu(BaseMenu):
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperSelectMcuIdMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.flash_options = FlashOptions()
self.mcu_list = self.flash_options.mcu_list
self.input_label_txt = "Select MCU to flash"
self.footer_type = FooterType.BACK_HELP
self.footer_type = FooterType.BACK
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
self.previous_menu: Type[BaseMenu] = (
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = (
previous_menu
if previous_menu is not None
else KlipperSelectMcuConnectionMenu
@@ -259,13 +270,12 @@ class KlipperSelectMcuIdMenu(BaseMenu):
def set_options(self) -> None:
self.options = {
f"{i}": Option(self.flash_mcu, False, f"{i}")
for i in range(len(self.mcu_list))
f"{i}": Option(self.flash_mcu, f"{i}") for i in range(len(self.mcu_list))
}
def print_menu(self) -> None:
header = "!!! ATTENTION !!!"
header2 = f"[{COLOR_CYAN}List of available MCUs{RESET_FORMAT}]"
header2 = f"[{COLOR_CYAN}List of detected MCUs{RESET_FORMAT}]"
color = COLOR_RED
count = 62 - len(color) - len(RESET_FORMAT)
menu = textwrap.dedent(
@@ -277,44 +287,58 @@ class KlipperSelectMcuIdMenu(BaseMenu):
║ ONLY flash a firmware created for the respective MCU! ║
║ ║
{header2:─^64}
║ ║
"""
)[1:]
for i, mcu in enumerate(self.mcu_list):
mcu = mcu.split("/")[-1]
menu += f" ● MCU #{i}: {COLOR_CYAN}{mcu}{RESET_FORMAT}\n"
menu += "╟───────────────────────────┬───────────────────────────╢"
menu += f" {i}) {COLOR_CYAN}{mcu:<51}{RESET_FORMAT}\n"
print(menu, end="\n")
menu += textwrap.dedent(
"""
║ ║
╟───────────────────────────────────────────────────────╢
"""
)[1:]
print(menu, end="")
def flash_mcu(self, **kwargs):
index = int(kwargs.get("opt_index"))
selected_mcu = self.mcu_list[index]
self.flash_options.selected_mcu = selected_mcu
try:
index: int | None = kwargs.get("opt_index", None)
if index is None:
raise Exception("opt_index is None")
if self.flash_options.flash_method == FlashMethod.SD_CARD:
KlipperSelectSDFlashBoardMenu(previous_menu=self.__class__).run()
elif self.flash_options.flash_method == FlashMethod.REGULAR:
KlipperFlashOverviewMenu(previous_menu=self.__class__).run()
index = int(index)
selected_mcu = self.mcu_list[index]
self.flash_options.selected_mcu = selected_mcu
if self.flash_options.flash_method == FlashMethod.SD_CARD:
KlipperSelectSDFlashBoardMenu(previous_menu=self.__class__).run()
elif self.flash_options.flash_method == FlashMethod.REGULAR:
KlipperFlashOverviewMenu(previous_menu=self.__class__).run()
except Exception as e:
Logger.print_error(e)
Logger.print_error("Flashing failed!")
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperSelectSDFlashBoardMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.flash_options = FlashOptions()
self.available_boards = get_sd_flash_board_list()
self.input_label_txt = "Select board type"
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
self.previous_menu: Type[BaseMenu] = (
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu = (
previous_menu if previous_menu is not None else KlipperSelectMcuIdMenu
)
def set_options(self) -> None:
self.options = {
f"{i}": Option(self.board_select, False, f"{i}")
f"{i}": Option(self.board_select, f"{i}")
for i in range(len(self.available_boards))
}
@@ -335,14 +359,22 @@ class KlipperSelectSDFlashBoardMenu(BaseMenu):
for i, board in enumerate(self.available_boards):
line = f" {i}) {board}"
menu += f"|{line:<55}|\n"
menu += f"{line:<55}\n"
menu += "╟───────────────────────────────────────────────────────╢"
print(menu, end="")
def board_select(self, **kwargs):
board = int(kwargs.get("opt_index"))
self.flash_options.selected_board = self.available_boards[board]
self.baudrate_select()
try:
index: int | None = kwargs.get("opt_index", None)
if index is None:
raise Exception("opt_index is None")
index = int(index)
self.flash_options.selected_board = self.available_boards[index]
self.baudrate_select()
except Exception as e:
Logger.print_error(e)
Logger.print_error("Board selection failed!")
def baudrate_select(self, **kwargs):
Logger.print_dialog(
@@ -366,21 +398,21 @@ class KlipperSelectSDFlashBoardMenu(BaseMenu):
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperFlashOverviewMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.flash_options = FlashOptions()
self.input_label_txt = "Perform action (default=Y)"
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
self.previous_menu: Type[BaseMenu] = previous_menu
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_options(self) -> None:
self.options = {
"Y": Option(self.execute_flash, menu=False),
"N": Option(self.abort_process, menu=False),
"y": Option(self.execute_flash),
"n": Option(self.abort_process),
}
self.default_option = Option(self.execute_flash, menu=False)
self.default_option = Option(self.execute_flash)
def print_menu(self) -> None:
header = "!!! ATTENTION !!!"
@@ -390,7 +422,7 @@ class KlipperFlashOverviewMenu(BaseMenu):
method = self.flash_options.flash_method.value
command = self.flash_options.flash_command.value
conn_type = self.flash_options.connection_type.value
mcu = self.flash_options.selected_mcu
mcu = self.flash_options.selected_mcu.split("/")[-1]
board = self.flash_options.selected_board
baudrate = self.flash_options.selected_baudrate
subheader = f"[{COLOR_CYAN}Overview{RESET_FORMAT}]"
@@ -404,26 +436,37 @@ class KlipperFlashOverviewMenu(BaseMenu):
║ sure everything is correct, start the process. If any ║
║ parameter needs to be changed, you can go back (B) ║
║ step by step or abort and start from the beginning. ║
{subheader:-^64}
{subheader:^64}
║ ║
"""
)[1:]
menu += f" ● MCU: {COLOR_CYAN}{mcu}{RESET_FORMAT}\n"
menu += f" ● Connection: {COLOR_CYAN}{conn_type}{RESET_FORMAT}\n"
menu += f" ● Flash method: {COLOR_CYAN}{method}{RESET_FORMAT}\n"
menu += f" ● Flash command: {COLOR_CYAN}{command}{RESET_FORMAT}\n"
menu += textwrap.dedent(
f"""
║ MCU: {COLOR_CYAN}{mcu:<48}{RESET_FORMAT}
║ Connection: {COLOR_CYAN}{conn_type:<41}{RESET_FORMAT}
║ Flash method: {COLOR_CYAN}{method:<39}{RESET_FORMAT}
║ Flash command: {COLOR_CYAN}{command:<38}{RESET_FORMAT}
"""
)[1:]
if self.flash_options.flash_method is FlashMethod.SD_CARD:
menu += f" ● Board type: {COLOR_CYAN}{board}{RESET_FORMAT}\n"
menu += f" ● Baudrate: {COLOR_CYAN}{baudrate}{RESET_FORMAT}\n"
menu += textwrap.dedent(
f"""
║ Board type: {COLOR_CYAN}{board:<41}{RESET_FORMAT}
║ Baudrate: {COLOR_CYAN}{baudrate:<43}{RESET_FORMAT}
"""
)[1:]
menu += textwrap.dedent(
"""
║ ║
╟───────────────────────────────────────────────────────╢
║ Y) Start flash process ║
║ N) Abort - Return to Advanced Menu ║
╟───────────────────────────────────────────────────────╢
"""
)
)[1:]
print(menu, end="")
def execute_flash(self, **kwargs):

View File

@@ -9,7 +9,7 @@
from pathlib import Path
from core.backup_manager import BACKUP_ROOT_DIR
from utils.constants import SYSTEMD
from core.constants import SYSTEMD
# repo
KLIPPERSCREEN_REPO = "https://github.com/KlipperScreen/KlipperScreen.git"

View File

@@ -26,28 +26,29 @@ from components.klipperscreen import (
)
from components.moonraker.moonraker import Moonraker
from core.backup_manager.backup_manager import BackupManager
from core.constants import SYSTEMD
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings
from core.types import ComponentStatus
from utils.common import (
check_install_dependencies,
get_install_status,
)
from utils.config_utils import add_config_section, remove_config_section
from utils.constants import SYSTEMD
from utils.fs_utils import remove_with_sudo
from utils.git_utils import (
git_clone_wrapper,
git_pull_wrapper,
)
from utils.input_utils import get_confirm
from utils.logger import DialogType, Logger
from utils.instance_utils import get_instances
from utils.sys_utils import (
check_python_version,
cmd_sysctl_service,
install_python_requirements,
remove_service_file,
remove_system_service,
)
from utils.types import ComponentStatus
def install_klipperscreen() -> None:
@@ -56,8 +57,7 @@ def install_klipperscreen() -> None:
if not check_python_version(3, 7):
return
mr_im = InstanceManager(Moonraker)
mr_instances = mr_im.instances
mr_instances = get_instances(Moonraker)
if not mr_instances:
Logger.print_dialog(
DialogType.WARNING,
@@ -68,8 +68,6 @@ def install_klipperscreen() -> None:
"KlipperScreens update manager configuration for Moonraker "
"will not be added to any moonraker.conf.",
],
padding_top=0,
padding_bottom=0,
)
if not get_confirm(
"Continue KlipperScreen installation?",
@@ -78,8 +76,7 @@ def install_klipperscreen() -> None:
):
return
package_list = ["git", "wget", "curl", "unzip", "dfu-util"]
check_install_dependencies(package_list)
check_install_dependencies()
git_clone_wrapper(KLIPPERSCREEN_REPO, KLIPPERSCREEN_DIR)
@@ -87,7 +84,7 @@ def install_klipperscreen() -> None:
run(KLIPPERSCREEN_INSTALL_SCRIPT.as_posix(), shell=True, check=True)
if mr_instances:
patch_klipperscreen_update_manager(mr_instances)
mr_im.restart_all_instance()
InstanceManager.restart_all(mr_instances)
else:
Logger.print_info(
"Moonraker is not installed! Cannot add "
@@ -106,8 +103,8 @@ def patch_klipperscreen_update_manager(instances: List[Moonraker]) -> None:
options=[
("type", "git_repo"),
("path", KLIPPERSCREEN_DIR.as_posix()),
("orgin", KLIPPERSCREEN_REPO),
("manages_servcies", "KlipperScreen"),
("origin", KLIPPERSCREEN_REPO),
("managed_services", "KlipperScreen"),
("env", f"{KLIPPERSCREEN_ENV_DIR}/bin/python"),
("requirements", KLIPPERSCREEN_REQ_FILE.as_posix()),
("install_script", KLIPPERSCREEN_INSTALL_SCRIPT.as_posix()),
@@ -167,10 +164,7 @@ def remove_klipperscreen() -> None:
Logger.print_warn("KlipperScreen environment not found!")
if KLIPPERSCREEN_SERVICE_FILE.exists():
remove_service_file(
KLIPPERSCREEN_SERVICE_NAME,
KLIPPERSCREEN_SERVICE_FILE,
)
remove_system_service(KLIPPERSCREEN_SERVICE_NAME)
logfile = Path(f"/tmp/{KLIPPERSCREEN_LOG_NAME}")
if logfile.exists():
@@ -178,17 +172,15 @@ def remove_klipperscreen() -> None:
remove_with_sudo(logfile)
Logger.print_ok("KlipperScreen log file successfully removed!")
kl_im = InstanceManager(Klipper)
kl_instances: List[Klipper] = kl_im.instances
kl_instances: List[Klipper] = get_instances(Klipper)
for instance in kl_instances:
logfile = instance.log_dir.joinpath(KLIPPERSCREEN_LOG_NAME)
logfile = instance.base.log_dir.joinpath(KLIPPERSCREEN_LOG_NAME)
if logfile.exists():
Logger.print_status(f"Removing {logfile} ...")
Path(logfile).unlink()
Logger.print_ok(f"{logfile} successfully removed!")
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
mr_instances: List[Moonraker] = get_instances(Moonraker)
if mr_instances:
Logger.print_status("Removing KlipperScreen from update manager ...")
remove_config_section("update_manager KlipperScreen", mr_instances)

View File

@@ -13,13 +13,14 @@ from typing import List
from components.klipper.klipper import Klipper
from components.log_uploads import LogFile
from core.instance_manager.instance_manager import InstanceManager
from utils.logger import Logger
from core.logger import Logger
from utils.instance_utils import get_instances
def get_logfile_list() -> List[LogFile]:
cm = InstanceManager(Klipper)
log_dirs: List[Path] = [instance.log_dir for instance in cm.instances]
log_dirs: List[Path] = [
instance.base.log_dir for instance in get_instances(Klipper)
]
logfiles: List[LogFile] = []
for _dir in log_dirs:

View File

@@ -6,37 +6,37 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.log_uploads.log_upload_utils import get_logfile_list, upload_logfile
from core.constants import COLOR_YELLOW, RESET_FORMAT
from core.logger import Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_YELLOW, RESET_FORMAT
# noinspection PyMethodMayBeStatic
class LogUploadMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.logfile_list = get_logfile_list()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self) -> None:
self.options = {
f"{index}": Option(self.upload, False, opt_index=f"{index}")
f"{index}": Option(self.upload, opt_index=f"{index}")
for index in range(len(self.logfile_list))
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ Log Upload ] "
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
@@ -58,5 +58,13 @@ class LogUploadMenu(BaseMenu):
print(menu, end="")
def upload(self, **kwargs):
index = int(kwargs.get("opt_index"))
upload_logfile(self.logfile_list[index])
try:
index: int | None = kwargs.get("opt_index", None)
if index is None:
raise Exception("opt_index is None")
index = int(index)
upload_logfile(self.logfile_list[index])
except Exception as e:
Logger.print_error(e)
Logger.print_error("Log upload failed!")

View File

@@ -1,206 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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
from pathlib import Path
from subprocess import CalledProcessError, run
from typing import List
from components.klipper.klipper import Klipper
from components.mobileraker import (
MOBILERAKER_BACKUP_DIR,
MOBILERAKER_DIR,
MOBILERAKER_ENV_DIR,
MOBILERAKER_INSTALL_SCRIPT,
MOBILERAKER_LOG_NAME,
MOBILERAKER_REPO,
MOBILERAKER_REQ_FILE,
MOBILERAKER_SERVICE_FILE,
MOBILERAKER_SERVICE_NAME,
MOBILERAKER_UPDATER_SECTION_NAME,
)
from components.moonraker.moonraker import Moonraker
from core.backup_manager.backup_manager import BackupManager
from core.instance_manager.instance_manager import InstanceManager
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies, get_install_status
from utils.config_utils import add_config_section, remove_config_section
from utils.git_utils import (
git_clone_wrapper,
git_pull_wrapper,
)
from utils.input_utils import get_confirm
from utils.logger import DialogType, Logger
from utils.sys_utils import (
check_python_version,
cmd_sysctl_service,
install_python_requirements,
remove_service_file,
)
from utils.types import ComponentStatus
def install_mobileraker() -> None:
Logger.print_status("Installing Mobileraker's companion ...")
if not check_python_version(3, 7):
return
mr_im = InstanceManager(Moonraker)
mr_instances = mr_im.instances
if not mr_instances:
Logger.print_dialog(
DialogType.WARNING,
[
"Moonraker not found! Mobileraker's companion will not properly work "
"without a working Moonraker installation.",
"Mobileraker's companion's update manager configuration for Moonraker "
"will not be added to any moonraker.conf.",
],
)
if not get_confirm(
"Continue Mobileraker's companion installation?",
default_choice=False,
allow_go_back=True,
):
return
check_install_dependencies()
git_clone_wrapper(MOBILERAKER_REPO, MOBILERAKER_DIR)
try:
run(MOBILERAKER_INSTALL_SCRIPT.as_posix(), shell=True, check=True)
if mr_instances:
patch_mobileraker_update_manager(mr_instances)
mr_im.restart_all_instance()
else:
Logger.print_info(
"Moonraker is not installed! Cannot add Mobileraker's "
"companion to update manager!"
)
Logger.print_ok("Mobileraker's companion successfully installed!")
except CalledProcessError as e:
Logger.print_error(f"Error installing Mobileraker's companion:\n{e}")
return
def patch_mobileraker_update_manager(instances: List[Moonraker]) -> None:
add_config_section(
section=MOBILERAKER_UPDATER_SECTION_NAME,
instances=instances,
options=[
("type", "git_repo"),
("path", MOBILERAKER_DIR.as_posix()),
("origin", MOBILERAKER_REPO),
("primary_branch", "main"),
("managed_services", "mobileraker"),
("env", f"{MOBILERAKER_ENV_DIR}/bin/python"),
("requirements", MOBILERAKER_REQ_FILE.as_posix()),
("install_script", MOBILERAKER_INSTALL_SCRIPT.as_posix()),
],
)
def update_mobileraker() -> None:
try:
if not MOBILERAKER_DIR.exists():
Logger.print_info(
"Mobileraker's companion does not seem to be installed! Skipping ..."
)
return
Logger.print_status("Updating Mobileraker's companion ...")
cmd_sysctl_service(MOBILERAKER_SERVICE_NAME, "stop")
settings = KiauhSettings()
if settings.kiauh.backup_before_update:
backup_mobileraker_dir()
git_pull_wrapper(MOBILERAKER_REPO, MOBILERAKER_DIR)
install_python_requirements(MOBILERAKER_ENV_DIR, MOBILERAKER_REQ_FILE)
cmd_sysctl_service(MOBILERAKER_SERVICE_NAME, "start")
Logger.print_ok("Mobileraker's companion updated successfully.", end="\n\n")
except CalledProcessError as e:
Logger.print_error(f"Error updating Mobileraker's companion:\n{e}")
return
def get_mobileraker_status() -> ComponentStatus:
return get_install_status(
MOBILERAKER_DIR,
MOBILERAKER_ENV_DIR,
files=[MOBILERAKER_SERVICE_FILE],
)
def remove_mobileraker() -> None:
Logger.print_status("Removing Mobileraker's companion ...")
try:
if MOBILERAKER_DIR.exists():
Logger.print_status("Removing Mobileraker's companion directory ...")
shutil.rmtree(MOBILERAKER_DIR)
Logger.print_ok("Mobileraker's companion directory successfully removed!")
else:
Logger.print_warn("Mobileraker's companion directory not found!")
if MOBILERAKER_ENV_DIR.exists():
Logger.print_status("Removing Mobileraker's companion environment ...")
shutil.rmtree(MOBILERAKER_ENV_DIR)
Logger.print_ok("Mobileraker's companion environment successfully removed!")
else:
Logger.print_warn("Mobileraker's companion environment not found!")
if MOBILERAKER_SERVICE_FILE.exists():
remove_service_file(
MOBILERAKER_SERVICE_NAME,
MOBILERAKER_SERVICE_FILE,
)
kl_im = InstanceManager(Klipper)
kl_instances: List[Klipper] = kl_im.instances
for instance in kl_instances:
logfile = instance.log_dir.joinpath(MOBILERAKER_LOG_NAME)
if logfile.exists():
Logger.print_status(f"Removing {logfile} ...")
Path(logfile).unlink()
Logger.print_ok(f"{logfile} successfully removed!")
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
if mr_instances:
Logger.print_status(
"Removing Mobileraker's companion from update manager ..."
)
remove_config_section(MOBILERAKER_UPDATER_SECTION_NAME, mr_instances)
Logger.print_ok(
"Mobileraker's companion successfully removed from update manager!"
)
Logger.print_ok("Mobileraker's companion successfully removed!")
except Exception as e:
Logger.print_error(f"Error removing Mobileraker's companion:\n{e}")
def backup_mobileraker_dir() -> None:
bm = BackupManager()
bm.backup_directory(
MOBILERAKER_DIR.name,
source=MOBILERAKER_DIR,
target=MOBILERAKER_BACKUP_DIR,
)
bm.backup_directory(
MOBILERAKER_ENV_DIR.name,
source=MOBILERAKER_ENV_DIR,
target=MOBILERAKER_BACKUP_DIR,
)

View File

@@ -6,42 +6,41 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.moonraker import moonraker_remove
from core.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
# noinspection PyUnusedLocal
class MoonrakerRemoveMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.remove_moonraker_service = False
self.remove_moonraker_dir = False
self.remove_moonraker_env = False
self.remove_moonraker_polkit = False
self.selection_state = False
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.remove_menu import RemoveMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else RemoveMenu
)
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
def set_options(self) -> None:
self.options = {
"a": Option(method=self.toggle_all, menu=False),
"1": Option(method=self.toggle_remove_moonraker_service, menu=False),
"2": Option(method=self.toggle_remove_moonraker_dir, menu=False),
"3": Option(method=self.toggle_remove_moonraker_env, menu=False),
"4": Option(method=self.toggle_remove_moonraker_polkit, menu=False),
"c": Option(method=self.run_removal_process, menu=False),
"a": Option(method=self.toggle_all),
"1": Option(method=self.toggle_remove_moonraker_service),
"2": Option(method=self.toggle_remove_moonraker_dir),
"3": Option(method=self.toggle_remove_moonraker_env),
"4": Option(method=self.toggle_remove_moonraker_polkit),
"c": Option(method=self.run_removal_process),
}
def print_menu(self) -> None:
@@ -76,11 +75,11 @@ class MoonrakerRemoveMenu(BaseMenu):
print(menu, end="")
def toggle_all(self, **kwargs) -> None:
self.remove_moonraker_service = not self.remove_moonraker_service
self.remove_moonraker_dir = not self.remove_moonraker_dir
self.remove_moonraker_env = not self.remove_moonraker_env
self.remove_moonraker_polkit = not self.remove_moonraker_polkit
self.selection_state = not self.selection_state
self.remove_moonraker_service = self.selection_state
self.remove_moonraker_dir = self.selection_state
self.remove_moonraker_env = self.selection_state
self.remove_moonraker_polkit = self.selection_state
def toggle_remove_moonraker_service(self, **kwargs) -> None:
self.remove_moonraker_service = not self.remove_moonraker_service

View File

@@ -8,10 +8,11 @@
# ======================================================================= #
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from subprocess import CalledProcessError, run
from subprocess import CalledProcessError
from components.klipper.klipper import Klipper
from components.moonraker import (
MOONRAKER_CFG_NAME,
MOONRAKER_DIR,
@@ -21,50 +22,60 @@ from components.moonraker import (
MOONRAKER_LOG_NAME,
MOONRAKER_SERVICE_TEMPLATE,
)
from core.constants import CURRENT_USER
from core.instance_manager.base_instance import BaseInstance
from core.logger import Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from utils.logger import Logger
from utils.fs_utils import create_folders
from utils.sys_utils import get_service_file_path
# noinspection PyMethodMayBeStatic
@dataclass
class Moonraker(BaseInstance):
class Moonraker:
suffix: str
base: BaseInstance = field(init=False, repr=False)
service_file_path: Path = field(init=False)
log_file_name: str = MOONRAKER_LOG_NAME
moonraker_dir: Path = MOONRAKER_DIR
env_dir: Path = MOONRAKER_ENV_DIR
cfg_file: Path = None
port: int = None
backup_dir: Path = None
certs_dir: Path = None
db_dir: Path = None
log: Path = None
data_dir: Path = field(init=False)
cfg_file: Path = field(init=False)
env_file: Path = field(init=False)
backup_dir: Path = field(init=False)
certs_dir: Path = field(init=False)
db_dir: Path = field(init=False)
port: int | None = field(init=False)
def __init__(self, suffix: str = ""):
super().__init__(instance_type=self, suffix=suffix)
def __post_init__(self):
self.base: BaseInstance = BaseInstance(Klipper, self.suffix)
self.base.log_file_name = self.log_file_name
def __post_init__(self) -> None:
super().__post_init__()
self.cfg_file = self.cfg_dir.joinpath(MOONRAKER_CFG_NAME)
self.port = self._get_port()
self.backup_dir = self.data_dir.joinpath("backup")
self.certs_dir = self.data_dir.joinpath("certs")
self.db_dir = self.data_dir.joinpath("database")
self.log = self.log_dir.joinpath(MOONRAKER_LOG_NAME)
self.service_file_path: Path = get_service_file_path(Moonraker, self.suffix)
self.data_dir: Path = self.base.data_dir
self.cfg_file: Path = self.base.cfg_dir.joinpath(MOONRAKER_CFG_NAME)
self.env_file: Path = self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME)
self.backup_dir: Path = self.base.data_dir.joinpath("backup")
self.certs_dir: Path = self.base.data_dir.joinpath("certs")
self.db_dir: Path = self.base.data_dir.joinpath("database")
self.port: int | None = self._get_port()
def create(self, create_example_cfg: bool = False) -> None:
def create(self) -> None:
from utils.sys_utils import create_env_file, create_service_file
Logger.print_status("Creating new Moonraker Instance ...")
try:
self.create_folders([self.backup_dir, self.certs_dir, self.db_dir])
create_folders(self.base.base_folders)
create_service_file(
name=self.get_service_file_name(extension=True),
name=self.service_file_path.name,
content=self._prep_service_file_content(),
)
create_env_file(
path=self.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME),
path=self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME),
content=self._prep_env_file_content(),
)
@@ -75,21 +86,6 @@ class Moonraker(BaseInstance):
Logger.print_error(f"Error creating env file: {e}")
raise
def delete(self) -> None:
service_file = self.get_service_file_name(extension=True)
service_file_path = self.get_service_file_path()
Logger.print_status(f"Removing Moonraker Instance: {service_file}")
try:
command = ["sudo", "rm", "-f", service_file_path]
run(command, check=True)
self.delete_logfiles(MOONRAKER_LOG_NAME)
Logger.print_ok("Instance successfully removed!")
except CalledProcessError as e:
Logger.print_error(f"Error removing instance: {e}")
raise
def _prep_service_file_content(self) -> str:
template = MOONRAKER_SERVICE_TEMPLATE
@@ -102,7 +98,7 @@ class Moonraker(BaseInstance):
service_content = template_content.replace(
"%USER%",
self.user,
CURRENT_USER,
)
service_content = service_content.replace(
"%MOONRAKER_DIR%",
@@ -114,7 +110,7 @@ class Moonraker(BaseInstance):
)
service_content = service_content.replace(
"%ENV_FILE%",
self.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME).as_posix(),
self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME).as_posix(),
)
return service_content
@@ -134,17 +130,17 @@ class Moonraker(BaseInstance):
)
env_file_content = env_file_content.replace(
"%PRINTER_DATA%",
self.data_dir.as_posix(),
self.base.data_dir.as_posix(),
)
return env_file_content
def _get_port(self) -> int | None:
if not self.cfg_file.is_file():
if not self.cfg_file or not self.cfg_file.is_file():
return None
scp = SimpleConfigParser()
scp.read(self.cfg_file)
port = scp.getint("server", "port", fallback=None)
scp.read_file(self.cfg_file)
port: int | None = scp.getint("server", "port", fallback=None)
return port

View File

@@ -12,8 +12,8 @@ from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from core.constants import COLOR_CYAN, COLOR_GREEN, COLOR_YELLOW, RESET_FORMAT
from core.menus.base_menu import print_back_footer
from utils.constants import COLOR_CYAN, COLOR_GREEN, COLOR_YELLOW, RESET_FORMAT
def print_moonraker_overview(
@@ -37,8 +37,8 @@ def print_moonraker_overview(
dialog += "║ ║\n"
instance_map = {
k.get_service_file_name(): (
k.get_service_file_name().replace("klipper", "moonraker")
k.service_file_path.stem: (
k.service_file_path.stem.replace("klipper", "moonraker")
if k.suffix in [m.suffix for m in moonraker_instances]
else ""
)

View File

@@ -6,18 +6,20 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from typing import List, Union
from typing import List
from components.klipper.klipper_dialogs import print_instance_overview
from components.moonraker import MOONRAKER_DIR, MOONRAKER_ENV_DIR
from components.moonraker.moonraker import Moonraker
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from utils.fs_utils import run_remove_routines
from utils.input_utils import get_selection_input
from utils.logger import Logger
from utils.sys_utils import cmd_sysctl_manage
from utils.instance_utils import get_instances
from utils.sys_utils import unit_file_exists
def run_moonraker_removal(
@@ -26,17 +28,18 @@ def run_moonraker_removal(
remove_env: bool,
remove_polkit: bool,
) -> None:
im = InstanceManager(Moonraker)
instances = get_instances(Moonraker)
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if im.instances:
instances_to_remove = select_instances_to_remove(im.instances)
remove_instances(im, instances_to_remove)
if instances:
instances_to_remove = select_instances_to_remove(instances)
remove_instances(instances_to_remove)
else:
Logger.print_info("No Moonraker Services installed! Skipped ...")
if (remove_polkit or remove_dir or remove_env) and im.instances:
delete_remaining: bool = remove_polkit or remove_dir or remove_env
if delete_remaining and unit_file_exists("moonraker", suffix="service"):
Logger.print_info("There are still other Moonraker services installed")
Logger.print_info(
"● Moonraker PolicyKit rules were not removed.", prefix=False
@@ -57,7 +60,7 @@ def run_moonraker_removal(
def select_instances_to_remove(
instances: List[Moonraker],
) -> Union[List[Moonraker], None]:
) -> List[Moonraker] | None:
start_index = 1
options = [str(i + start_index) for i in range(len(instances))]
options.extend(["a", "b"])
@@ -83,17 +86,15 @@ def select_instances_to_remove(
def remove_instances(
instance_manager: InstanceManager,
instance_list: List[Moonraker],
instance_list: List[Moonraker] | None,
) -> None:
if not instance_list:
Logger.print_info("No Moonraker instances found. Skipped ...")
return
for instance in instance_list:
Logger.print_status(f"Removing instance {instance.get_service_file_name()} ...")
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance()
cmd_sysctl_manage("daemon-reload")
Logger.print_status(f"Removing instance {instance.service_file_path.stem} ...")
InstanceManager.remove(instance)
delete_moonraker_env_file(instance)
def remove_polkit_rules() -> None:
@@ -111,14 +112,10 @@ def remove_polkit_rules() -> None:
Logger.print_ok("Policykit rules successfully removed!")
def delete_moonraker_logs(instances: List[Moonraker]) -> None:
all_logfiles = []
for instance in instances:
all_logfiles = list(instance.log_dir.glob("moonraker.log*"))
if not all_logfiles:
Logger.print_info("No Moonraker logs found. Skipped ...")
def delete_moonraker_env_file(instance: Moonraker):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
for log in all_logfiles:
Logger.print_status(f"Remove '{log}'")
run_remove_routines(log)
run_remove_routines(instance.env_file)

View File

@@ -38,6 +38,7 @@ from components.webui_client.client_utils import (
)
from components.webui_client.mainsail_data import MainsailData
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies
from utils.fs_utils import check_file_exist
@@ -46,10 +47,11 @@ from utils.input_utils import (
get_confirm,
get_selection_input,
)
from utils.logger import Logger
from utils.instance_utils import get_instances
from utils.sys_utils import (
check_python_version,
cmd_sysctl_manage,
cmd_sysctl_service,
create_python_venv,
install_python_requirements,
parse_packages_from_file,
@@ -57,18 +59,17 @@ from utils.sys_utils import (
def install_moonraker() -> None:
if not check_moonraker_install_requirements():
klipper_list: List[Klipper] = get_instances(Klipper)
if not check_moonraker_install_requirements(klipper_list):
return
klipper_list: List[Klipper] = InstanceManager(Klipper).instances
mr_im = InstanceManager(Moonraker)
moonraker_list: List[Moonraker] = mr_im.instances
instance_names = []
moonraker_list: List[Moonraker] = get_instances(Moonraker)
instances: List[Moonraker] = []
selected_option: str | Klipper
if len(klipper_list) == 0:
instance_names.append(klipper_list[0].suffix)
if len(klipper_list) == 1:
instances.append(Moonraker(klipper_list[0].suffix))
else:
print_moonraker_overview(
klipper_list,
@@ -87,9 +88,12 @@ def install_moonraker() -> None:
return
if selected_option == "a":
instance_names.extend([k.suffix for k in klipper_list])
instances.extend([Moonraker(k.suffix) for k in klipper_list])
else:
instance_names.append(options.get(selected_option).suffix)
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
instances.append(Moonraker(klipper_instance.suffix))
create_example_cfg = get_confirm("Create example moonraker.conf?")
@@ -99,26 +103,23 @@ def install_moonraker() -> None:
install_moonraker_polkit()
used_ports_map = {m.suffix: m.port for m in moonraker_list}
for name in instance_names:
current_instance = Moonraker(suffix=name)
mr_im.current_instance = current_instance
mr_im.create_instance()
mr_im.enable_instance()
for instance in instances:
instance.create()
cmd_sysctl_service(instance.service_file_path.name, "enable")
if create_example_cfg:
# if a webclient and/or it's config is installed, patch
# its update section to the config
clients = get_existing_clients()
create_example_moonraker_conf(current_instance, used_ports_map, clients)
create_example_moonraker_conf(instance, used_ports_map, clients)
mr_im.start_instance()
cmd_sysctl_service(instance.service_file_path.name, "start")
cmd_sysctl_manage("daemon-reload")
# if mainsail is installed, and we installed
# multiple moonraker instances, we enable mainsails remote mode
if MainsailData().client_dir.exists() and len(mr_im.instances) > 1:
if MainsailData().client_dir.exists() and len(moonraker_list) > 1:
enable_mainsail_remotemode()
except Exception as e:
@@ -126,9 +127,9 @@ def install_moonraker() -> None:
return
def check_moonraker_install_requirements() -> bool:
def check_moonraker_install_requirements(klipper_list: List[Klipper]) -> bool:
def check_klipper_instances() -> bool:
if len(InstanceManager(Klipper).instances) >= 1:
if len(klipper_list) >= 1:
return True
Logger.print_warn("Klipper not installed!")
@@ -147,9 +148,9 @@ def setup_moonraker_prerequesites() -> None:
# install moonraker dependencies and create python virtualenv
install_moonraker_packages()
create_python_venv(MOONRAKER_ENV_DIR)
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE)
if create_python_venv(MOONRAKER_ENV_DIR):
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE)
def install_moonraker_packages() -> None:
@@ -164,7 +165,7 @@ def install_moonraker_packages() -> None:
if not moonraker_deps:
raise ValueError("Error reading Moonraker dependencies!")
check_install_dependencies(moonraker_deps)
check_install_dependencies({*moonraker_deps})
def install_moonraker_polkit() -> None:
@@ -205,8 +206,8 @@ def update_moonraker() -> None:
if settings.kiauh.backup_before_update:
backup_moonraker_dir()
instance_manager = InstanceManager(Moonraker)
instance_manager.stop_all_instance()
instances = get_instances(Moonraker)
InstanceManager.stop_all(instances)
git_pull_wrapper(repo=settings.moonraker.repo_url, target_dir=MOONRAKER_DIR)
@@ -215,4 +216,4 @@ def update_moonraker() -> None:
# install possible new python dependencies
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
instance_manager.start_all_instance()
InstanceManager.start_all(instances)

View File

@@ -21,16 +21,16 @@ from components.moonraker import (
from components.moonraker.moonraker import Moonraker
from components.webui_client.base_data import BaseWebClient
from core.backup_manager.backup_manager import BackupManager
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from core.types import ComponentStatus
from utils.common import get_install_status
from utils.logger import Logger
from utils.instance_utils import get_instances
from utils.sys_utils import (
get_ipv4_addr,
)
from utils.types import ComponentStatus
def get_moonraker_status() -> ComponentStatus:
@@ -42,7 +42,7 @@ def create_example_moonraker_conf(
ports_map: Dict[str, int],
clients: Optional[List[BaseWebClient]] = None,
) -> None:
Logger.print_status(f"Creating example moonraker.conf in '{instance.cfg_dir}'")
Logger.print_status(f"Creating example moonraker.conf in '{instance.base.cfg_dir}'")
if instance.cfg_file.is_file():
Logger.print_info(f"'{instance.cfg_file}' already exists.")
return
@@ -74,23 +74,18 @@ def create_example_moonraker_conf(
ip = get_ipv4_addr().split(".")[:2]
ip.extend(["0", "0/16"])
uds = instance.comms_dir.joinpath("klippy.sock")
uds = instance.base.comms_dir.joinpath("klippy.sock")
scp = SimpleConfigParser()
scp.read(target)
scp.read_file(target)
trusted_clients: List[str] = [
".".join(ip),
*scp.get("authorization", "trusted_clients"),
f" {'.'.join(ip)}\n",
*scp.getval("authorization", "trusted_clients"),
]
scp.set("server", "port", str(port))
scp.set("server", "klippy_uds_address", str(uds))
scp.set(
"authorization",
"trusted_clients",
"\n".join(trusted_clients),
True,
)
scp.set_option("server", "port", str(port))
scp.set_option("server", "klippy_uds_address", str(uds))
scp.set_option("authorization", "trusted_clients", trusted_clients)
# add existing client and client configs in the update section
if clients is not None and len(clients) > 0:
@@ -105,7 +100,7 @@ def create_example_moonraker_conf(
]
scp.add_section(section=c_section)
for option in c_options:
scp.set(c_section, option[0], option[1])
scp.set_option(c_section, option[0], option[1])
# client config part
c_config = c.client_config
@@ -120,13 +115,13 @@ def create_example_moonraker_conf(
]
scp.add_section(section=c_config_section)
for option in c_config_options:
scp.set(c_config_section, option[0], option[1])
scp.set_option(c_config_section, option[0], option[1])
scp.write(target)
Logger.print_ok(f"Example moonraker.conf created in '{instance.cfg_dir}'")
scp.write_file(target)
Logger.print_ok(f"Example moonraker.conf created in '{instance.base.cfg_dir}'")
def backup_moonraker_dir():
def backup_moonraker_dir() -> None:
bm = BackupManager()
bm.backup_directory("moonraker", source=MOONRAKER_DIR, target=MOONRAKER_BACKUP_DIR)
bm.backup_directory(
@@ -135,12 +130,11 @@ def backup_moonraker_dir():
def backup_moonraker_db_dir() -> None:
im = InstanceManager(Moonraker)
instances: List[Moonraker] = im.instances
instances: List[Moonraker] = get_instances(Moonraker)
bm = BackupManager()
for instance in instances:
name = f"database-{instance.data_dir_name}"
name = f"database-{instance.data_dir.name}"
bm.backup_directory(
name, source=instance.db_dir, target=MOONRAKER_DB_BACKUP_DIR
)

View File

@@ -1,90 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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
from subprocess import CalledProcessError, run
from typing import List
from components.moonraker import MOONRAKER_CFG_NAME
from components.octoeverywhere import (
OE_CFG_NAME,
OE_DIR,
OE_ENV_DIR,
OE_INSTALL_SCRIPT,
OE_LOG_NAME,
OE_STORE_DIR,
OE_SYS_CFG_NAME,
OE_UPDATE_SCRIPT,
)
from core.instance_manager.base_instance import BaseInstance
from utils.logger import Logger
class Octoeverywhere(BaseInstance):
@classmethod
def blacklist(cls) -> List[str]:
return ["None", "mcu", "bambu", "companion"]
def __init__(self, suffix: str = ""):
super().__init__(instance_type=self, suffix=suffix)
self.dir: Path = OE_DIR
self.env_dir: Path = OE_ENV_DIR
self.store_dir: Path = OE_STORE_DIR
self._cfg_file = self.cfg_dir.joinpath(OE_CFG_NAME)
self._sys_cfg_file = self.cfg_dir.joinpath(OE_SYS_CFG_NAME)
self._log = self.log_dir.joinpath(OE_LOG_NAME)
@property
def cfg_file(self) -> Path:
return self._cfg_file
@property
def sys_cfg_file(self) -> Path:
return self._sys_cfg_file
@property
def log(self) -> Path:
return self._log
def create(self) -> None:
Logger.print_status("Creating OctoEverywhere for Klipper Instance ...")
try:
cmd = f"{OE_INSTALL_SCRIPT} {self.cfg_dir}/{MOONRAKER_CFG_NAME}"
run(cmd, check=True, shell=True)
except CalledProcessError as e:
Logger.print_error(f"Error creating instance: {e}")
raise
@staticmethod
def update():
try:
run(OE_UPDATE_SCRIPT.as_posix(), check=True, shell=True, cwd=OE_DIR)
except CalledProcessError as e:
Logger.print_error(f"Error updating OctoEverywhere for Klipper: {e}")
raise
def delete(self) -> None:
service_file = self.get_service_file_name(extension=True)
service_file_path = self.get_service_file_path()
Logger.print_status(
f"Deleting OctoEverywhere for Klipper Instance: {service_file}"
)
try:
command = ["sudo", "rm", "-f", service_file_path]
run(command, check=True)
self.delete_logfiles(OE_LOG_NAME)
Logger.print_ok(f"Service file deleted: {service_file_path}")
except CalledProcessError as e:
Logger.print_error(f"Error deleting service file: {e}")
raise

View File

@@ -1,210 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 json
from typing import List
from components.moonraker.moonraker import Moonraker
from components.octoeverywhere import (
OE_DEPS_JSON_FILE,
OE_DIR,
OE_ENV_DIR,
OE_INSTALL_SCRIPT,
OE_INSTALLER_LOG_FILE,
OE_REPO,
OE_REQ_FILE,
OE_SYS_CFG_NAME,
)
from components.octoeverywhere.octoeverywhere import Octoeverywhere
from core.instance_manager.instance_manager import InstanceManager
from utils.common import (
check_install_dependencies,
get_install_status,
moonraker_exists,
)
from utils.config_utils import (
remove_config_section,
)
from utils.fs_utils import run_remove_routines
from utils.git_utils import git_clone_wrapper
from utils.input_utils import get_confirm
from utils.logger import DialogType, Logger
from utils.sys_utils import (
cmd_sysctl_manage,
install_python_requirements,
parse_packages_from_file,
)
from utils.types import ComponentStatus
def get_octoeverywhere_status() -> ComponentStatus:
return get_install_status(OE_DIR, OE_ENV_DIR, Octoeverywhere)
def install_octoeverywhere() -> None:
Logger.print_status("Installing OctoEverywhere for Klipper ...")
# check if moonraker is installed. if not, notify the user and exit
if not moonraker_exists():
return
force_clone = False
oe_im = InstanceManager(Octoeverywhere)
oe_instances: List[Octoeverywhere] = oe_im.instances
if oe_instances:
Logger.print_dialog(
DialogType.INFO,
[
"OctoEverywhere is already installed!",
"It is safe to run the installer again to link your "
"printer or repair any issues.",
],
padding_top=0,
padding_bottom=0,
)
if not get_confirm("Re-run OctoEverywhere installation?"):
Logger.print_info("Exiting OctoEverywhere for Klipper installation ...")
return
else:
Logger.print_status("Re-Installing OctoEverywhere for Klipper ...")
force_clone = True
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
mr_names = [f"{moonraker.data_dir_name}" for moonraker in mr_instances]
if len(mr_names) > 1:
Logger.print_dialog(
DialogType.INFO,
[
"The following Moonraker instances were found:",
*mr_names,
"\n\n",
"The setup will apply the same names to OctoEverywhere!",
],
padding_top=0,
padding_bottom=0,
)
if not get_confirm(
"Continue OctoEverywhere for Klipper installation?",
default_choice=True,
allow_go_back=True,
):
Logger.print_info("Exiting OctoEverywhere for Klipper installation ...")
return
try:
git_clone_wrapper(OE_REPO, OE_DIR, force=force_clone)
for moonraker in mr_instances:
oe_im.current_instance = Octoeverywhere(suffix=moonraker.suffix)
oe_im.create_instance()
mr_im.restart_all_instance()
Logger.print_dialog(
DialogType.SUCCESS,
["OctoEverywhere for Klipper successfully installed!"],
center_content=True,
)
except Exception as e:
Logger.print_error(
f"Error during OctoEverywhere for Klipper installation:\n{e}"
)
def update_octoeverywhere() -> None:
Logger.print_status("Updating OctoEverywhere for Klipper ...")
try:
Octoeverywhere.update()
Logger.print_dialog(
DialogType.SUCCESS,
["OctoEverywhere for Klipper successfully updated!"],
center_content=True,
)
except Exception as e:
Logger.print_error(f"Error during OctoEverywhere for Klipper update:\n{e}")
def remove_octoeverywhere() -> None:
Logger.print_status("Removing OctoEverywhere for Klipper ...")
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
ob_im = InstanceManager(Octoeverywhere)
ob_instances: List[Octoeverywhere] = ob_im.instances
try:
remove_oe_instances(ob_im, ob_instances)
remove_oe_dir()
remove_oe_env()
remove_config_section(f"include {OE_SYS_CFG_NAME}", mr_instances)
run_remove_routines(OE_INSTALLER_LOG_FILE)
Logger.print_dialog(
DialogType.SUCCESS,
["OctoEverywhere for Klipper successfully removed!"],
center_content=True,
)
except Exception as e:
Logger.print_error(f"Error during OctoEverywhere for Klipper removal:\n{e}")
def install_oe_dependencies() -> None:
oe_deps = []
if OE_DEPS_JSON_FILE.exists():
with open(OE_DEPS_JSON_FILE, "r") as deps:
oe_deps = json.load(deps).get("debian", [])
elif OE_INSTALL_SCRIPT.exists():
oe_deps = parse_packages_from_file(OE_INSTALL_SCRIPT)
if not oe_deps:
raise ValueError("Error reading OctoEverywhere dependencies!")
check_install_dependencies(oe_deps)
install_python_requirements(OE_ENV_DIR, OE_REQ_FILE)
def remove_oe_instances(
instance_manager: InstanceManager,
instance_list: List[Octoeverywhere],
) -> None:
if not instance_list:
Logger.print_info("No OctoEverywhere instances found. Skipped ...")
return
for instance in instance_list:
Logger.print_status(f"Removing instance {instance.get_service_file_name()} ...")
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance()
cmd_sysctl_manage("daemon-reload")
def remove_oe_dir() -> None:
Logger.print_status("Removing OctoEverywhere for Klipper directory ...")
if not OE_DIR.exists():
Logger.print_info(f"'{OE_DIR}' does not exist. Skipped ...")
return
run_remove_routines(OE_DIR)
def remove_oe_env() -> None:
Logger.print_status("Removing OctoEverywhere for Klipper environment ...")
if not OE_ENV_DIR.exists():
Logger.print_info(f"'{OE_ENV_DIR}' does not exist. Skipped ...")
return
run_remove_routines(OE_ENV_DIR)

View File

@@ -9,7 +9,8 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import ABC
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@@ -24,89 +25,32 @@ class WebClientConfigType(Enum):
FLUIDD: str = "fluidd-config"
@dataclass()
class BaseWebClient(ABC):
"""Base class for webclient data"""
@property
@abstractmethod
def client(self) -> WebClientType:
raise NotImplementedError
@property
@abstractmethod
def name(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def display_name(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def client_dir(self) -> Path:
raise NotImplementedError
@property
@abstractmethod
def backup_dir(self) -> Path:
raise NotImplementedError
@property
@abstractmethod
def repo_path(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def download_url(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def client_config(self) -> BaseWebClientConfig:
raise NotImplementedError
client: WebClientType
name: str
display_name: str
client_dir: Path
config_file: Path
backup_dir: Path
repo_path: str
download_url: str
nginx_access_log: Path
nginx_error_log: Path
client_config: BaseWebClientConfig
@dataclass()
class BaseWebClientConfig(ABC):
"""Base class for webclient config data"""
@property
@abstractmethod
def client_config(self) -> WebClientConfigType:
raise NotImplementedError
@property
@abstractmethod
def name(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def display_name(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def config_filename(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def config_dir(self) -> Path:
raise NotImplementedError
@property
@abstractmethod
def backup_dir(self) -> Path:
raise NotImplementedError
@property
@abstractmethod
def repo_url(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def config_section(self) -> str:
raise NotImplementedError
client_config: WebClientConfigType
name: str
display_name: str
config_filename: str
config_dir: Path
backup_dir: Path
repo_url: str
config_section: str

View File

@@ -13,10 +13,10 @@ from typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from components.webui_client.base_data import BaseWebClientConfig
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from utils.config_utils import remove_config_section
from utils.fs_utils import run_remove_routines
from utils.logger import Logger
from utils.instance_utils import get_instances
def run_client_config_removal(
@@ -36,7 +36,8 @@ def remove_client_config_dir(client_config: BaseWebClientConfig) -> None:
def remove_client_config_symlink(client_config: BaseWebClientConfig) -> None:
im = InstanceManager(Klipper)
instances: List[Klipper] = im.instances
instances: List[Klipper] = get_instances(Klipper)
for instance in instances:
run_remove_routines(instance.cfg_dir.joinpath(client_config.config_filename))
run_remove_routines(
instance.base.cfg_dir.joinpath(client_config.config_filename)
)

View File

@@ -6,6 +6,7 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import shutil
import subprocess
@@ -23,16 +24,17 @@ from components.webui_client.client_utils import (
detect_client_cfg_conflict,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import backup_printer_config_dir
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.logger import Logger
from utils.instance_utils import get_instances
def install_client_config(client_data: BaseWebClient) -> None:
def install_client_config(client_data: BaseWebClient, cfg_backup=True) -> None:
client_config: BaseWebClientConfig = client_data.client_config
display_name = client_config.display_name
@@ -47,16 +49,15 @@ def install_client_config(client_data: BaseWebClient) -> None:
else:
return
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
kl_im = InstanceManager(Klipper)
kl_instances = kl_im.instances
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)
backup_printer_config_dir()
if cfg_backup:
backup_printer_config_dir()
add_config_section(
section=f"update_manager {client_config.name}",
@@ -70,7 +71,7 @@ def install_client_config(client_data: BaseWebClient) -> None:
],
)
add_config_section_at_top(client_config.config_section, kl_instances)
kl_im.restart_all_instance()
InstanceManager.restart_all(kl_instances)
except Exception as e:
Logger.print_error(f"{display_name} installation failed!\n{e}")
@@ -112,16 +113,12 @@ def update_client_config(client: BaseWebClient) -> None:
def create_client_config_symlink(
client_config: BaseWebClientConfig, klipper_instances: List[Klipper] = None
client_config: BaseWebClientConfig, klipper_instances: List[Klipper]
) -> None:
if klipper_instances is None:
kl_im = InstanceManager(Klipper)
klipper_instances = kl_im.instances
Logger.print_status(f"Create symlink for {client_config.config_filename} ...")
source = Path(client_config.config_dir, client_config.config_filename)
for instance in klipper_instances:
target = instance.cfg_dir
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)

View File

@@ -10,54 +10,55 @@
from typing import List
from components.webui_client.base_data import BaseWebClient
from utils.logger import DialogType, Logger
from core.logger import DialogType, Logger
def print_moonraker_not_found_dialog():
def print_moonraker_not_found_dialog(name: str) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"No local Moonraker installation was found!",
"\n\n",
"It is possible to install Mainsail without a local Moonraker installation. "
f"It is possible to install {name} without a local Moonraker installation. "
"If you continue, you need to make sure, that Moonraker is installed on "
"another machine in your network. Otherwise Mainsail will NOT work "
f"another machine in your network. Otherwise {name} will NOT work "
"correctly.",
],
padding_top=0,
padding_bottom=0,
)
def print_client_already_installed_dialog(name: str):
def print_client_already_installed_dialog(name: str) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
f"{name} seems to be already installed!",
f"If you continue, your current {name} installation will be overwritten.",
],
padding_top=0,
padding_bottom=0,
)
def print_client_port_select_dialog(name: str, port: int, ports_in_use: List[int]):
Logger.print_dialog(
DialogType.CUSTOM,
[
f"Please select the port, {name} should be served on. If your are unsure "
f"what to select, hit Enter to apply the suggested value of: {port}",
"\n\n",
f"In case you need {name} to be served on a specific port, you can set it "
f"now. Make sure that the port is not already used by another application "
f"on your system!",
"\n\n",
"The following ports were found to be in use already:",
*[f"{port}" for port in ports_in_use],
],
padding_top=0,
padding_bottom=0,
)
def print_client_port_select_dialog(
name: str, port: int, ports_in_use: List[int]
) -> None:
dialog_content: List[str] = [
f"Please select the port, {name} should be served on. If your are unsure "
f"what to select, hit Enter to apply the suggested value of: {port}",
"\n\n",
f"In case you need {name} to be served on a specific port, you can set it "
f"now. Make sure that the port is not already used by another application "
f"on your system!",
]
if ports_in_use:
dialog_content.extend(
[
"\n\n",
"The following ports were found to be already in use:",
*[f"{p}" for p in ports_in_use if p != port],
]
)
Logger.print_dialog(DialogType.CUSTOM, dialog_content)
def print_install_client_config_dialog(client: BaseWebClient) -> None:
@@ -75,8 +76,6 @@ def print_install_client_config_dialog(client: BaseWebClient) -> None:
"If you already use these macros skip this step. Otherwise you should "
"consider to answer with 'Y' to download the recommended macros.",
],
padding_top=0,
padding_bottom=0,
)

View File

@@ -6,49 +6,45 @@
# #
# 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,
WebClientType,
)
from components.webui_client.client_config.client_config_remove import (
run_client_config_removal,
)
from components.webui_client.client_utils import backup_mainsail_config_json
from core.instance_manager.instance_manager import InstanceManager
from core.backup_manager.backup_manager import BackupManager
from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED
from core.logger import Logger
from utils.config_utils import remove_config_section
from utils.fs_utils import (
remove_nginx_config,
remove_nginx_logs,
remove_with_sudo,
run_remove_routines,
)
from utils.logger import Logger
from utils.instance_utils import get_instances
def run_client_removal(
client: BaseWebClient,
remove_client: bool,
remove_client_cfg: bool,
backup_ms_config_json: bool,
backup_config: bool,
) -> None:
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
kl_im = InstanceManager(Klipper)
kl_instances: List[Klipper] = kl_im.instances
mr_instances: List[Moonraker] = get_instances(Moonraker)
kl_instances: List[Klipper] = get_instances(Klipper)
if backup_ms_config_json and client.client == WebClientType.MAINSAIL:
backup_mainsail_config_json()
if backup_config:
bm = BackupManager()
bm.backup_file(client.config_file)
if remove_client:
client_name = client.name
remove_client_dir(client)
remove_nginx_config(client_name)
remove_nginx_logs(client_name, kl_instances)
remove_client_nginx_config(client_name)
remove_client_nginx_logs(client, kl_instances)
section = f"update_manager {client_name}"
remove_config_section(section, mr_instances)
@@ -64,3 +60,26 @@ def run_client_removal(
def remove_client_dir(client: BaseWebClient) -> None:
Logger.print_status(f"Removing {client.display_name} ...")
run_remove_routines(client.client_dir)
def remove_client_nginx_config(name: str) -> None:
Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...")
remove_with_sudo(NGINX_SITES_AVAILABLE.joinpath(name))
remove_with_sudo(NGINX_SITES_ENABLED.joinpath(name))
def remove_client_nginx_logs(client: BaseWebClient, instances: List[Klipper]) -> None:
Logger.print_status(f"Removing NGINX logs for {client.display_name} ...")
remove_with_sudo(client.nginx_access_log)
remove_with_sudo(client.nginx_error_log)
if not instances:
return
for instance in instances:
run_remove_routines(
instance.base.log_dir.joinpath(client.nginx_access_log.name)
)
run_remove_routines(instance.base.log_dir.joinpath(client.nginx_error_log.name))

View File

@@ -6,7 +6,8 @@
# #
# 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
@@ -22,32 +23,26 @@ from components.webui_client.client_config.client_config_setup import (
install_client_config,
)
from components.webui_client.client_dialogs import (
print_client_port_select_dialog,
print_install_client_config_dialog,
print_moonraker_not_found_dialog,
)
from components.webui_client.client_utils import (
backup_mainsail_config_json,
detect_client_cfg_conflict,
enable_mainsail_remotemode,
restore_mainsail_config_json,
symlink_webui_nginx_log,
)
from core.instance_manager.instance_manager import InstanceManager
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies
from utils.config_utils import add_config_section
from utils.fs_utils import (
copy_common_vars_nginx_cfg,
copy_upstream_nginx_cfg,
create_nginx_cfg,
get_next_free_port,
is_valid_port,
read_ports_from_nginx_configs,
unzip,
detect_client_cfg_conflict,
enable_mainsail_remotemode,
get_client_port_selection,
symlink_webui_nginx_log,
)
from utils.input_utils import get_confirm, get_number_input
from utils.logger import Logger
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogCustomColor, DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import backup_printer_config_dir, 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,
@@ -55,22 +50,16 @@ from utils.sys_utils import (
)
def install_client(client: BaseWebClient) -> None:
if client is None:
raise ValueError("Missing parameter client_data!")
if client.client_dir.exists():
Logger.print_info(
f"{client.display_name} seems to be already installed! Skipped ..."
)
return
mr_im = InstanceManager(Moonraker)
mr_instances: List[Moonraker] = mr_im.instances
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()
print_moonraker_not_found_dialog(client.display_name)
if not get_confirm(f"Continue {client.display_name} installation?"):
return
@@ -83,8 +72,7 @@ def install_client(client: BaseWebClient) -> None:
):
enable_remotemode = True
kl_im = InstanceManager(Klipper)
kl_instances = kl_im.instances
kl_instances = get_instances(Klipper)
install_client_cfg = False
client_config: BaseWebClientConfig = client.client_config
if (
@@ -96,42 +84,33 @@ def install_client(client: BaseWebClient) -> None:
question = f"Download the recommended {client_config.display_name}?"
install_client_cfg = get_confirm(question, allow_go_back=False)
settings = KiauhSettings()
port: int = settings.get(client.name, "port")
ports_in_use: List[int] = read_ports_from_nginx_configs()
default_port: int = int(settings.get(client.name, "port"))
port: int = (
default_port if reinstall else get_client_port_selection(client, settings)
)
# check if configured port is a valid number and not in use already
valid_port = is_valid_port(port, ports_in_use)
while not valid_port:
next_port = get_next_free_port(ports_in_use)
print_client_port_select_dialog(client.display_name, next_port, ports_in_use)
port = get_number_input(
f"Configure {client.display_name} for port",
min_count=int(next_port),
default=next_port,
)
valid_port = is_valid_port(port, ports_in_use)
check_install_dependencies(["nginx"])
check_install_dependencies({"nginx"})
try:
download_client(client)
if enable_remotemode and client.client == WebClientType.MAINSAIL:
enable_mainsail_remotemode()
if mr_instances:
add_config_section(
section=f"update_manager {client.name}",
instances=mr_instances,
options=[
("type", "web"),
("channel", "stable"),
("repo", str(client.repo_path)),
("path", str(client.client_dir)),
],
)
mr_im.restart_all_instance()
backup_printer_config_dir()
add_config_section(
section=f"update_manager {client.name}",
instances=mr_instances,
options=[
("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)
install_client_config(client, False)
copy_upstream_nginx_cfg()
copy_common_vars_nginx_cfg()
@@ -145,16 +124,28 @@ def install_client(client: BaseWebClient) -> None:
)
if kl_instances:
symlink_webui_nginx_log(kl_instances)
symlink_webui_nginx_log(client, kl_instances)
cmd_sysctl_service("nginx", "restart")
except Exception as e:
Logger.print_error(f"{client.display_name} installation failed!\n{e}")
Logger.print_error(e)
Logger.print_dialog(
DialogType.ERROR,
center_content=True,
content=[f"{client.display_name} installation failed!"],
)
return
log = f"Open {client.display_name} now on: http://{get_ipv4_addr()}:{port}"
Logger.print_ok(f"{client.display_name} installation complete!", start="\n")
Logger.print_ok(log, prefix=False, end="\n\n")
# noinspection HttpUrlsUsage
Logger.print_dialog(
DialogType.CUSTOM,
custom_title=f"{client.display_name} installation complete!",
custom_color=DialogCustomColor.GREEN,
center_content=True,
content=[
f"Open {client.display_name} now on: http://{get_ipv4_addr()}:{port}",
],
)
def download_client(client: BaseWebClient) -> None:
@@ -185,10 +176,10 @@ def update_client(client: BaseWebClient) -> None:
)
return
if client.client == WebClientType.MAINSAIL:
backup_mainsail_config_json(is_temp=True)
download_client(client)
if client.client == WebClientType.MAINSAIL:
restore_mainsail_config_json()
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)

View File

@@ -9,28 +9,44 @@
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
from subprocess import PIPE, CalledProcessError, run
from typing import List, get_args
from components.klipper.klipper import Klipper
from components.webui_client import MODULE_PATH
from components.webui_client.base_data import (
BaseWebClient,
WebClientType,
)
from components.webui_client.client_dialogs import print_client_port_select_dialog
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from core.backup_manager.backup_manager import BackupManager
from core.settings.kiauh_settings import KiauhSettings
from utils import NGINX_CONFD, NGINX_SITES_AVAILABLE
from core.constants import (
COLOR_CYAN,
COLOR_YELLOW,
NGINX_CONFD,
NGINX_SITES_AVAILABLE,
NGINX_SITES_ENABLED,
RESET_FORMAT,
)
from core.logger import Logger
from core.settings.kiauh_settings import KiauhSettings, WebUiSettings
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from core.types import ComponentStatus
from utils.common import get_install_status
from utils.constants import COLOR_CYAN, COLOR_YELLOW, RESET_FORMAT
from utils.fs_utils import create_symlink, remove_file
from utils.git_utils import (
get_latest_tag,
get_latest_remote_tag,
get_latest_unstable_tag,
)
from utils.logger import Logger
from utils.types import ComponentStatus
from utils.input_utils import get_number_input
from utils.instance_utils import get_instances
def get_client_status(
@@ -57,40 +73,46 @@ def get_client_config_status(client: BaseWebClient) -> ComponentStatus:
return get_install_status(client.client_config.config_dir)
def get_current_client_config(clients: List[BaseWebClient]) -> str:
installed = []
for client in clients:
client_config = client.client_config
if client_config.config_dir.exists():
installed.append(client)
def get_current_client_config() -> str:
mainsail, fluidd = MainsailData(), FluiddData()
clients: List[BaseWebClient] = [mainsail, fluidd]
installed = [c for c in clients if c.client_config.config_dir.exists()]
if len(installed) > 1:
return f"{COLOR_YELLOW}Conflict!{RESET_FORMAT}"
if not installed:
return f"{COLOR_CYAN}-{RESET_FORMAT}"
elif len(installed) == 1:
cfg = installed[0].client_config
return f"{COLOR_CYAN}{cfg.display_name}{RESET_FORMAT}"
return f"{COLOR_CYAN}-{RESET_FORMAT}"
# at this point, both client config folders exists, so we need to check
# which are actually included in the printer.cfg of all klipper instances
mainsail_includes, fluidd_includes = [], []
klipper_instances: List[Klipper] = get_instances(Klipper)
for instance in klipper_instances:
scp = SimpleConfigParser()
scp.read_file(instance.cfg_file)
includes_mainsail = scp.has_section(mainsail.client_config.config_section)
includes_fluidd = scp.has_section(fluidd.client_config.config_section)
if includes_mainsail:
mainsail_includes.append(instance)
if includes_fluidd:
fluidd_includes.append(instance)
def backup_mainsail_config_json(is_temp=False) -> None:
c_json = MainsailData().client_dir.joinpath("config.json")
bm = BackupManager()
if is_temp:
fn = Path.home().joinpath("config.json.kiauh.bak")
bm.backup_file(c_json, custom_filename=fn)
# if both are included in the same file, we have a potential conflict
if includes_mainsail and includes_fluidd:
return f"{COLOR_YELLOW}Conflict!{RESET_FORMAT}"
if not mainsail_includes and not fluidd_includes:
# there are no includes at all, even though the client config folders exist
return f"{COLOR_CYAN}-{RESET_FORMAT}"
elif len(fluidd_includes) > len(mainsail_includes):
# there are more instances that include fluidd than mainsail
return f"{COLOR_CYAN}{fluidd.client_config.display_name}{RESET_FORMAT}"
else:
bm.backup_file(c_json)
def restore_mainsail_config_json() -> None:
try:
c_json = MainsailData().client_dir.joinpath("config.json")
Logger.print_status(f"Restore '{c_json}' ...")
source = Path.home().joinpath("config.json.kiauh.bak")
shutil.copy(source, c_json)
except OSError:
Logger.print_info("Unable to restore config.json. Skipped ...")
# there are the same amount of non-conflicting includes for each config
# or more instances include mainsail than fluidd
return f"{COLOR_CYAN}{mainsail.client_config.display_name}{RESET_FORMAT}"
def enable_mainsail_remotemode() -> None:
@@ -111,17 +133,19 @@ def enable_mainsail_remotemode() -> None:
Logger.print_ok("Mainsails remote mode enabled!")
def symlink_webui_nginx_log(klipper_instances: List[Klipper]) -> None:
def symlink_webui_nginx_log(
client: BaseWebClient, klipper_instances: List[Klipper]
) -> None:
Logger.print_status("Link NGINX logs into log directory ...")
access_log = Path("/var/log/nginx/mainsail-access.log")
error_log = Path("/var/log/nginx/mainsail-error.log")
access_log = client.nginx_access_log
error_log = client.nginx_error_log
for instance in klipper_instances:
desti_access = instance.log_dir.joinpath("mainsail-access.log")
desti_access = instance.base.log_dir.joinpath(access_log.name)
if not desti_access.exists():
desti_access.symlink_to(access_log)
desti_error = instance.log_dir.joinpath("mainsail-error.log")
desti_error = instance.base.log_dir.joinpath(error_log.name)
if not desti_error.exists():
desti_error.symlink_to(error_log)
@@ -137,7 +161,7 @@ def get_local_client_version(client: BaseWebClient) -> str | None:
if relinfo_file.is_file():
with open(relinfo_file, "r") as f:
return json.load(f)["version"]
return str(json.load(f)["version"])
else:
with open(version_file, "r") as f:
return f.readlines()[0]
@@ -145,8 +169,8 @@ def get_local_client_version(client: BaseWebClient) -> str | None:
def get_remote_client_version(client: BaseWebClient) -> str | None:
try:
if (tag := get_latest_tag(client.repo_path)) != "":
return tag
if (tag := get_latest_remote_tag(client.repo_path)) != "":
return str(tag)
return None
except Exception:
return None
@@ -162,9 +186,7 @@ def backup_client_data(client: BaseWebClient) -> None:
bm = BackupManager()
bm.backup_directory(f"{name}-{version}", src, dest)
if name == "mainsail":
c_json = MainsailData().client_dir.joinpath("config.json")
bm.backup_file(c_json, dest)
bm.backup_file(client.config_file, dest)
bm.backup_file(NGINX_SITES_AVAILABLE.joinpath(name), dest)
@@ -222,3 +244,186 @@ def get_download_url(base_url: str, client: BaseWebClient) -> str:
return f"{base_url}/download/{unstable_tag}/{client.name}.zip"
except Exception:
return stable_url
#################################################
## NGINX RELATED FUNCTIONS
#################################################
def copy_upstream_nginx_cfg() -> None:
"""
Creates an upstream.conf in /etc/nginx/conf.d
:return: None
"""
source = MODULE_PATH.joinpath("assets/upstreams.conf")
target = NGINX_CONFD.joinpath("upstreams.conf")
try:
command = ["sudo", "cp", source, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
Logger.print_error(log)
raise
def copy_common_vars_nginx_cfg() -> None:
"""
Creates a common_vars.conf in /etc/nginx/conf.d
:return: None
"""
source = MODULE_PATH.joinpath("assets/common_vars.conf")
target = NGINX_CONFD.joinpath("common_vars.conf")
try:
command = ["sudo", "cp", source, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
Logger.print_error(log)
raise
def generate_nginx_cfg_from_template(name: str, template_src: Path, **kwargs) -> None:
"""
Creates an NGINX config from a template file and
replaces all placeholders passed as kwargs. A placeholder must be defined
in the template file as %{placeholder}%.
:param name: name of the config to create
:param template_src: the path to the template file
:return: None
"""
tmp = Path.home().joinpath(f"{name}.tmp")
shutil.copy(template_src, tmp)
with open(tmp, "r+") as f:
content = f.read()
for key, value in kwargs.items():
content = content.replace(f"%{key}%", str(value))
f.seek(0)
f.write(content)
f.truncate()
target = NGINX_SITES_AVAILABLE.joinpath(name)
try:
command = ["sudo", "mv", tmp, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create '{target}': {e.stderr.decode()}"
Logger.print_error(log)
raise
def create_nginx_cfg(
display_name: str,
cfg_name: str,
template_src: Path,
**kwargs,
) -> None:
from utils.sys_utils import set_nginx_permissions
try:
Logger.print_status(f"Creating NGINX config for {display_name} ...")
source = NGINX_SITES_AVAILABLE.joinpath(cfg_name)
target = NGINX_SITES_ENABLED.joinpath(cfg_name)
remove_file(Path("/etc/nginx/sites-enabled/default"), True)
generate_nginx_cfg_from_template(cfg_name, template_src=template_src, **kwargs)
create_symlink(source, target, True)
set_nginx_permissions()
Logger.print_ok(f"NGINX config for {display_name} successfully created.")
except Exception:
Logger.print_error(f"Creating NGINX config for {display_name} failed!")
raise
def read_ports_from_nginx_configs() -> List[int]:
"""
Helper function to iterate over all NGINX configs and read all ports defined for listen
:return: A sorted list of listen ports
"""
if not NGINX_SITES_ENABLED.exists():
return []
port_list = []
for config in NGINX_SITES_ENABLED.iterdir():
if not config.is_file():
continue
with open(config, "r") as cfg:
lines = cfg.readlines()
for line in lines:
line = re.sub(
r"default_server|http://|https://|[;\[\]]",
"",
line.strip(),
)
if line.startswith("listen"):
if ":" not in line:
port_list.append(line.split()[-1])
else:
port_list.append(line.split(":")[-1])
ports_to_ints_list = [int(port) for port in port_list]
return sorted(ports_to_ints_list, key=lambda x: int(x))
def get_client_port_selection(
client: BaseWebClient,
settings: KiauhSettings,
reconfigure=False,
) -> int:
default_port: int = int(settings.get(client.name, "port"))
ports_in_use: List[int] = read_ports_from_nginx_configs()
next_free_port: int = get_next_free_port(ports_in_use)
port: int = (
next_free_port
if not reconfigure and default_port in ports_in_use
else default_port
)
print_client_port_select_dialog(client.display_name, port, ports_in_use)
while True:
_type = "Reconfigure" if reconfigure else "Configure"
question = f"{_type} {client.display_name} for port"
port_input = get_number_input(question, min_count=80, default=port)
if port_input not in ports_in_use:
client_settings: WebUiSettings = settings[client.name]
client_settings.port = port_input
settings.save()
return port_input
Logger.print_error("This port is already in use. Please select another one.")
def get_next_free_port(ports_in_use: List[int]) -> int:
valid_ports = set(range(80, 7125))
used_ports = set(map(int, ports_in_use))
return min(valid_ports - used_ports)
def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> None:
"""
Set the port the client should listen on in the NGINX config
:param curr_port: The current port the client listens on
:param new_port: The new port to set
:param client: The client to set the port for
:return: None
"""
config = NGINX_SITES_AVAILABLE.joinpath(client.name)
with open(config, "r") as f:
lines = f.readlines()
for i, line in enumerate(lines):
if "listen" in line:
lines[i] = line.replace(str(curr_port), str(new_port))
with open(config, "w") as f:
f.writelines(lines)

View File

@@ -21,7 +21,7 @@ from components.webui_client.base_data import (
from core.backup_manager import BACKUP_ROOT_DIR
@dataclass(frozen=True)
@dataclass()
class FluiddConfigWeb(BaseWebClientConfig):
client_config: WebClientConfigType = WebClientConfigType.FLUIDD
name: str = client_config.value
@@ -33,7 +33,7 @@ class FluiddConfigWeb(BaseWebClientConfig):
repo_url: str = "https://github.com/fluidd-core/fluidd-config.git"
@dataclass(frozen=True)
@dataclass()
class FluiddData(BaseWebClient):
BASE_DL_URL = "https://github.com/fluidd-core/fluidd/releases"
@@ -41,15 +41,16 @@ class FluiddData(BaseWebClient):
name: str = client.value
display_name: str = name.capitalize()
client_dir: Path = Path.home().joinpath("fluidd")
config_file: Path = client_dir.joinpath("config.json")
backup_dir: Path = BACKUP_ROOT_DIR.joinpath("fluidd-backups")
repo_path: str = "fluidd-core/fluidd"
nginx_access_log: Path = Path("/var/log/nginx/fluidd-access.log")
nginx_error_log: Path = Path("/var/log/nginx/fluidd-error.log")
client_config: BaseWebClientConfig = None
download_url: str | None = None
@property
def download_url(self) -> str:
def __post_init__(self):
from components.webui_client.client_utils import get_download_url
return get_download_url(self.BASE_DL_URL, self)
@property
def client_config(self) -> BaseWebClientConfig:
return FluiddConfigWeb()
self.client_config = FluiddConfigWeb()
self.download_url = get_download_url(self.BASE_DL_URL, self)

View File

@@ -21,7 +21,7 @@ from components.webui_client.base_data import (
from core.backup_manager import BACKUP_ROOT_DIR
@dataclass(frozen=True)
@dataclass()
class MainsailConfigWeb(BaseWebClientConfig):
client_config: WebClientConfigType = WebClientConfigType.MAINSAIL
name: str = client_config.value
@@ -33,7 +33,7 @@ class MainsailConfigWeb(BaseWebClientConfig):
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
@dataclass(frozen=True)
@dataclass()
class MainsailData(BaseWebClient):
BASE_DL_URL: str = "https://github.com/mainsail-crew/mainsail/releases"
@@ -41,15 +41,16 @@ class MainsailData(BaseWebClient):
name: str = WebClientType.MAINSAIL.value
display_name: str = name.capitalize()
client_dir: Path = Path.home().joinpath("mainsail")
config_file: Path = client_dir.joinpath("config.json")
backup_dir: Path = BACKUP_ROOT_DIR.joinpath("mainsail-backups")
repo_path: str = "mainsail-crew/mainsail"
nginx_access_log: Path = Path("/var/log/nginx/mainsail-access.log")
nginx_error_log: Path = Path("/var/log/nginx/mainsail-error.log")
client_config: BaseWebClientConfig = None
download_url: str | None = None
@property
def download_url(self) -> str:
def __post_init__(self):
from components.webui_client.client_utils import get_download_url
return get_download_url(self.BASE_DL_URL, self)
@property
def client_config(self) -> BaseWebClientConfig:
return MainsailConfigWeb()
self.client_config = MainsailConfigWeb()
self.download_url = get_download_url(self.BASE_DL_URL, self)

View File

@@ -0,0 +1,104 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 textwrap
from typing import Type
from components.webui_client.base_data import BaseWebClient
from components.webui_client.client_setup import install_client
from components.webui_client.client_utils import (
get_client_port_selection,
set_listen_port,
)
from core.constants import COLOR_CYAN, COLOR_GREEN, RESET_FORMAT
from core.logger import DialogCustomColor, DialogType, Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.settings.kiauh_settings import KiauhSettings, WebUiSettings
from utils.sys_utils import cmd_sysctl_service, get_ipv4_addr
# noinspection PyUnusedLocal
class ClientInstallMenu(BaseMenu):
def __init__(
self, client: BaseWebClient, previous_menu: Type[BaseMenu] | None = None
):
super().__init__()
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.client: BaseWebClient = client
self.settings = KiauhSettings()
self.client_settings: WebUiSettings = self.settings[client.name]
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.install_menu import InstallMenu
self.previous_menu = previous_menu if previous_menu is not None else InstallMenu
def set_options(self) -> None:
self.options = {
"1": Option(method=self.reinstall_client),
"2": Option(method=self.change_listen_port),
}
def print_menu(self) -> None:
client_name = self.client.display_name
header = f" [ Installation Menu > {client_name} ] "
color = COLOR_GREEN
count = 62 - len(color) - len(RESET_FORMAT)
port = f"(Current: {COLOR_CYAN}{int(self.client_settings.port)}{RESET_FORMAT})"
menu = textwrap.dedent(
f"""
╔═══════════════════════════════════════════════════════╗
{color}{header:~^{count}}{RESET_FORMAT}
╟───────────────────────────────────────────────────────╢
║ 1) Reinstall {client_name:16}
║ 2) Reconfigure Listen Port {port:<34}
╟───────────────────────────────────────────────────────╢
"""
)[1:]
print(menu, end="")
def reinstall_client(self, **kwargs) -> None:
install_client(self.client, settings=self.settings, reinstall=True)
def change_listen_port(self, **kwargs) -> None:
curr_port = int(self.client_settings.port)
new_port = get_client_port_selection(
self.client,
self.settings,
reconfigure=True,
)
cmd_sysctl_service("nginx", "stop")
set_listen_port(self.client, curr_port, new_port)
Logger.print_status("Saving new port configuration ...")
self.client_settings.port = new_port
self.settings.save()
Logger.print_ok("Port configuration saved!")
cmd_sysctl_service("nginx", "start")
# noinspection HttpUrlsUsage
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Port reconfiguration complete!",
custom_color=DialogCustomColor.GREEN,
center_content=True,
content=[
f"Open {self.client.display_name} now on: "
f"http://{get_ipv4_addr()}:{new_port}",
],
)
def _go_back(self, **kwargs) -> None:
if self.previous_menu is not None:
self.previous_menu().run()

View File

@@ -6,46 +6,44 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.webui_client import client_remove
from components.webui_client.base_data import BaseWebClient, WebClientType
from components.webui_client.base_data import BaseWebClient
from core.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, COLOR_RED, RESET_FORMAT
# noinspection PyUnusedLocal
class ClientRemoveMenu(BaseMenu):
def __init__(
self, client: BaseWebClient, previous_menu: Optional[Type[BaseMenu]] = None
self, client: BaseWebClient, previous_menu: Type[BaseMenu] | None = None
):
super().__init__()
self.previous_menu = previous_menu
self.client = client
self.remove_client = False
self.remove_client_cfg = False
self.backup_mainsail_config_json = False
self.selection_state = False
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.client: BaseWebClient = client
self.remove_client: bool = False
self.remove_client_cfg: bool = False
self.backup_config_json: bool = False
self.selection_state: bool = False
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.remove_menu import RemoveMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else RemoveMenu
)
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
def set_options(self) -> None:
self.options = {
"a": Option(method=self.toggle_all, menu=False),
"1": Option(method=self.toggle_rm_client, menu=False),
"2": Option(method=self.toggle_rm_client_config, menu=False),
"c": Option(method=self.run_removal_process, menu=False),
"a": Option(method=self.toggle_all),
"1": Option(method=self.toggle_rm_client),
"2": Option(method=self.toggle_rm_client_config),
"3": Option(method=self.toggle_backup_config_json),
"c": Option(method=self.run_removal_process),
}
if self.client.client == WebClientType.MAINSAIL:
self.options["3"] = Option(self.toggle_backup_mainsail_config_json, False)
def print_menu(self) -> None:
client_name = self.client.display_name
@@ -59,6 +57,7 @@ class ClientRemoveMenu(BaseMenu):
unchecked = "[ ]"
o1 = checked if self.remove_client else unchecked
o2 = checked if self.remove_client_cfg else unchecked
o3 = checked if self.backup_config_json else unchecked
menu = textwrap.dedent(
f"""
╔═══════════════════════════════════════════════════════╗
@@ -71,19 +70,7 @@ class ClientRemoveMenu(BaseMenu):
╟───────────────────────────────────────────────────────╢
║ 1) {o1} Remove {client_name:16}
║ 2) {o2} Remove {client_config_name:24}
"""
)[1:]
if self.client.client == WebClientType.MAINSAIL:
o3 = checked if self.backup_mainsail_config_json else unchecked
menu += textwrap.dedent(
f"""
║ 3) {o3} Backup config.json ║
"""
)[1:]
menu += textwrap.dedent(
"""
║ 3) {o3} Backup config.json ║
╟───────────────────────────────────────────────────────╢
║ C) Continue ║
╟───────────────────────────────────────────────────────╢
@@ -92,10 +79,10 @@ class ClientRemoveMenu(BaseMenu):
print(menu, end="")
def toggle_all(self, **kwargs) -> None:
self.remove_client = not self.remove_client
self.remove_client_cfg = not self.remove_client_cfg
self.backup_mainsail_config_json = not self.backup_mainsail_config_json
self.selection_state = not self.selection_state
self.remove_client = self.selection_state
self.remove_client_cfg = self.selection_state
self.backup_config_json = self.selection_state
def toggle_rm_client(self, **kwargs) -> None:
self.remove_client = not self.remove_client
@@ -103,14 +90,14 @@ class ClientRemoveMenu(BaseMenu):
def toggle_rm_client_config(self, **kwargs) -> None:
self.remove_client_cfg = not self.remove_client_cfg
def toggle_backup_mainsail_config_json(self, **kwargs) -> None:
self.backup_mainsail_config_json = not self.backup_mainsail_config_json
def toggle_backup_config_json(self, **kwargs) -> None:
self.backup_config_json = not self.backup_config_json
def run_removal_process(self, **kwargs) -> None:
if (
not self.remove_client
and not self.remove_client_cfg
and not self.backup_mainsail_config_json
and not self.backup_config_json
):
error = f"{COLOR_RED}Nothing selected ...{RESET_FORMAT}"
print(error)
@@ -120,12 +107,12 @@ class ClientRemoveMenu(BaseMenu):
client=self.client,
remove_client=self.remove_client,
remove_client_cfg=self.remove_client_cfg,
backup_ms_config_json=self.backup_mainsail_config_json,
backup_config=self.backup_config_json,
)
self.remove_client = False
self.remove_client_cfg = False
self.backup_mainsail_config_json = False
self.backup_config_json = False
self._go_back()

View File

@@ -6,22 +6,27 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import shutil
from pathlib import Path
from typing import List
from core.backup_manager import BACKUP_ROOT_DIR
from core.logger import Logger
from utils.common import get_current_date
from utils.logger import Logger
class BackupManagerException(Exception):
pass
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class BackupManager:
def __init__(self, backup_root_dir: Path = BACKUP_ROOT_DIR):
self._backup_root_dir = backup_root_dir
self._ignore_folders = None
self._backup_root_dir: Path = backup_root_dir
self._ignore_folders: List[str] = []
@property
def backup_root_dir(self) -> Path:
@@ -39,7 +44,7 @@ class BackupManager:
def ignore_folders(self, value: List[str]):
self._ignore_folders = value
def backup_file(self, file: Path, target: Path = None, custom_filename=None):
def backup_file(self, file: Path, target: Path | None = None, custom_filename=None):
Logger.print_status(f"Creating backup of {file} ...")
if not file.exists():
@@ -62,7 +67,9 @@ class BackupManager:
else:
Logger.print_info(f"File '{file}' not found ...")
def backup_directory(self, name: str, source: Path, target: Path = None) -> None:
def backup_directory(
self, name: str, source: Path, target: Path | None = None
) -> Path | None:
Logger.print_status(f"Creating backup of {name} in {target} ...")
if source is None or not Path(source).exists():
@@ -73,19 +80,19 @@ class BackupManager:
try:
date = get_current_date().get("date")
time = get_current_date().get("time")
shutil.copytree(
source,
target.joinpath(f"{name.lower()}-{date}-{time}"),
ignore=self.ignore_folders_func,
)
backup_target = target.joinpath(f"{name.lower()}-{date}-{time}")
shutil.copytree(source, backup_target, ignore=self.ignore_folders_func)
Logger.print_ok("Backup successful!")
return backup_target
except OSError as e:
Logger.print_error(f"Unable to backup directory '{source}':\n{e}")
return
raise BackupManagerException(f"Unable to backup directory '{source}':\n{e}")
def ignore_folders_func(self, dirpath, filenames):
def ignore_folders_func(self, dirpath, filenames) -> List[str]:
return (
[f for f in filenames if f in self._ignore_folders]
if self._ignore_folders is not None
if self._ignore_folders
else []
)

View File

@@ -11,6 +11,8 @@ import os
import pwd
from pathlib import Path
from core.backup_manager import BACKUP_ROOT_DIR
# text colors and formats
COLOR_WHITE = "\033[37m" # white
COLOR_MAGENTA = "\033[35m" # magenta
@@ -19,6 +21,19 @@ COLOR_YELLOW = "\033[93m" # bright yellow
COLOR_RED = "\033[91m" # bright red
COLOR_CYAN = "\033[96m" # bright cyan
RESET_FORMAT = "\033[0m" # reset format
# global dependencies
GLOBAL_DEPS = ["git", "wget", "curl", "unzip", "dfu-util", "python3-virtualenv"]
# strings
INVALID_CHOICE = "Invalid choice. Please select a valid value."
# current user
CURRENT_USER = pwd.getpwuid(os.getuid())[0]
# dirs
SYSTEMD = Path("/etc/systemd/system")
PRINTER_DATA_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("printer-data-backups")
NGINX_SITES_AVAILABLE = Path("/etc/nginx/sites-available")
NGINX_SITES_ENABLED = Path("/etc/nginx/sites-enabled")
NGINX_CONFD = Path("/etc/nginx/conf.d")

View File

@@ -6,12 +6,14 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import warnings
from typing import Callable
def deprecated(info: str = "", replaced_by: Callable = None) -> Callable:
def decorator(func):
def deprecated(info: str = "", replaced_by: Callable | None = None) -> Callable:
def decorator(func) -> Callable:
def wrapper(*args, **kwargs):
msg = f"{info}{replaced_by.__name__ if replaced_by else ''}"
warnings.warn(msg, category=DeprecationWarning, stacklevel=2)

View File

@@ -10,112 +10,49 @@
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from typing import List
from utils.constants import CURRENT_USER, SYSTEMD
from utils.logger import Logger
from utils.fs_utils import get_data_dir
SUFFIX_BLACKLIST: List[str] = ["None", "mcu", "obico", "bambu", "companion"]
@dataclass
class BaseInstance(ABC):
instance_type: BaseInstance
@dataclass(repr=True)
class BaseInstance:
instance_type: type
suffix: str
user: str = field(default=CURRENT_USER, init=False)
data_dir: Path = None
data_dir_name: str = ""
is_legacy_instance: bool = False
cfg_dir: Path = None
log_dir: Path = None
comms_dir: Path = None
sysd_dir: Path = None
gcodes_dir: Path = None
log_file_name: str | None = None
data_dir: Path = field(init=False)
base_folders: List[Path] = field(init=False)
cfg_dir: Path = field(init=False)
log_dir: Path = field(init=False)
gcodes_dir: Path = field(init=False)
comms_dir: Path = field(init=False)
sysd_dir: Path = field(init=False)
is_legacy_instance: bool = field(init=False)
def __post_init__(self) -> None:
self._set_data_dir()
self._set_is_legacy_instance()
def __post_init__(self):
self.data_dir = get_data_dir(self.instance_type, self.suffix)
# the following attributes require the data_dir to be set
self.cfg_dir = self.data_dir.joinpath("config")
self.log_dir = self.data_dir.joinpath("logs")
self.gcodes_dir = self.data_dir.joinpath("gcodes")
self.comms_dir = self.data_dir.joinpath("comms")
self.sysd_dir = self.data_dir.joinpath("systemd")
self.gcodes_dir = self.data_dir.joinpath("gcodes")
@classmethod
def blacklist(cls) -> List[str]:
return ["None", "mcu", "obico", "bambu", "companion"]
@abstractmethod
def create(self) -> None:
raise NotImplementedError("Subclasses must implement the create method")
@abstractmethod
def delete(self) -> None:
raise NotImplementedError("Subclasses must implement the delete method")
def create_folders(self, add_dirs: Optional[List[Path]] = None) -> None:
dirs = [
self.is_legacy_instance = self._set_is_legacy_instance()
self.base_folders = [
self.data_dir,
self.cfg_dir,
self.log_dir,
self.gcodes_dir,
self.comms_dir,
self.sysd_dir,
self.gcodes_dir,
]
if add_dirs:
dirs.extend(add_dirs)
def _set_is_legacy_instance(self) -> bool:
legacy_pattern = r"^(?!printer)(.+)_data"
match = re.search(legacy_pattern, self.data_dir.name)
for _dir in dirs:
_dir.mkdir(exist_ok=True)
# todo: refactor into a set method and access the value by accessing the property
def get_service_file_name(self, extension: bool = False) -> str:
from utils.common import convert_camelcase_to_kebabcase
name = convert_camelcase_to_kebabcase(self.__class__.__name__)
if self.suffix != "":
name += f"-{self.suffix}"
return name if not extension else f"{name}.service"
# todo: refactor into a set method and access the value by accessing the property
def get_service_file_path(self) -> Path:
return SYSTEMD.joinpath(self.get_service_file_name(extension=True))
def delete_logfiles(self, log_name: str) -> None:
from utils.fs_utils import run_remove_routines
if not self.log_dir.exists():
return
files = self.log_dir.iterdir()
logs = [f for f in files if f.name.startswith(log_name)]
for log in logs:
Logger.print_status(f"Remove '{log}'")
run_remove_routines(log)
def _set_data_dir(self) -> None:
if self.suffix == "":
self.data_dir = Path.home().joinpath("printer_data")
else:
self.data_dir = Path.home().joinpath(f"printer_{self.suffix}_data")
if self.get_service_file_path().exists():
with open(self.get_service_file_path(), "r") as service_file:
service_content = service_file.read()
pattern = re.compile("^EnvironmentFile=(.+)(/systemd/.+\.env)")
match = re.search(pattern, service_content)
if match:
self.data_dir = Path(match.group(1))
def _set_is_legacy_instance(self) -> None:
if (
self.suffix != ""
and not self.data_dir_name.startswith("printer_")
and not self.data_dir_name.endswith("_data")
):
self.is_legacy_instance = True
else:
self.is_legacy_instance = False
return True if (match and self.suffix != "") else False

View File

@@ -6,189 +6,103 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import List, Optional, TypeVar, Union
from subprocess import CalledProcessError
from typing import List
from core.instance_manager.base_instance import BaseInstance
from utils.constants import SYSTEMD
from utils.logger import Logger
from core.logger import Logger
from utils.instance_type import InstanceType
from utils.sys_utils import cmd_sysctl_service
T = TypeVar(name="T", bound=BaseInstance, covariant=True)
# noinspection PyMethodMayBeStatic
class InstanceManager:
def __init__(self, instance_type: T) -> None:
self._instance_type = instance_type
self._current_instance: Optional[T] = None
self._instance_suffix: Optional[str] = None
self._instance_service: Optional[str] = None
self._instance_service_full: Optional[str] = None
self._instance_service_path: Optional[str] = None
self._instances: List[T] = []
@property
def instance_type(self) -> T:
return self._instance_type
@instance_type.setter
def instance_type(self, value: T):
self._instance_type = value
@property
def current_instance(self) -> T:
return self._current_instance
@current_instance.setter
def current_instance(self, value: T) -> None:
self._current_instance = value
self.instance_suffix = value.suffix
self.instance_service = value.get_service_file_name()
self.instance_service_path = value.get_service_file_path()
@property
def instance_suffix(self) -> str:
return self._instance_suffix
@instance_suffix.setter
def instance_suffix(self, value: str):
self._instance_suffix = value
@property
def instance_service(self) -> str:
return self._instance_service
@instance_service.setter
def instance_service(self, value: str):
self._instance_service = value
@property
def instance_service_full(self) -> str:
return f"{self._instance_service}.service"
@property
def instance_service_path(self) -> str:
return self._instance_service_path
@instance_service_path.setter
def instance_service_path(self, value: str):
self._instance_service_path = value
@property
def instances(self) -> List[T]:
return self.find_instances()
@instances.setter
def instances(self, value: List[T]):
self._instances = value
def create_instance(self) -> None:
if self.current_instance is not None:
try:
self.current_instance.create()
except (OSError, subprocess.CalledProcessError) as e:
Logger.print_error(f"Creating instance failed: {e}")
raise
else:
raise ValueError("current_instance cannot be None")
def delete_instance(self) -> None:
if self.current_instance is not None:
try:
self.current_instance.delete()
except (OSError, subprocess.CalledProcessError) as e:
Logger.print_error(f"Removing instance failed: {e}")
raise
else:
raise ValueError("current_instance cannot be None")
def enable_instance(self) -> None:
@staticmethod
def enable(instance: InstanceType) -> None:
service_name: str = instance.service_file_path.name
try:
cmd_sysctl_service(self.instance_service_full, "enable")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error enabling service {self.instance_service_full}:")
cmd_sysctl_service(service_name, "enable")
except CalledProcessError as e:
Logger.print_error(f"Error enabling service {service_name}:")
Logger.print_error(f"{e}")
def disable_instance(self) -> None:
@staticmethod
def disable(instance: InstanceType) -> None:
service_name: str = instance.service_file_path.name
try:
cmd_sysctl_service(self.instance_service_full, "disable")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error disabling {self.instance_service_full}:")
Logger.print_error(f"{e}")
def start_instance(self) -> None:
try:
cmd_sysctl_service(self.instance_service_full, "start")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error starting {self.instance_service_full}:")
Logger.print_error(f"{e}")
def restart_instance(self) -> None:
try:
cmd_sysctl_service(self.instance_service_full, "restart")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error restarting {self.instance_service_full}:")
Logger.print_error(f"{e}")
def start_all_instance(self) -> None:
for instance in self.instances:
self.current_instance = instance
self.start_instance()
def restart_all_instance(self) -> None:
for instance in self.instances:
self.current_instance = instance
self.restart_instance()
def stop_instance(self) -> None:
try:
cmd_sysctl_service(self.instance_service_full, "stop")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error stopping {self.instance_service_full}:")
Logger.print_error(f"{e}")
cmd_sysctl_service(service_name, "disable")
except CalledProcessError as e:
Logger.print_error(f"Error disabling {service_name}: {e}")
raise
def stop_all_instance(self) -> None:
for instance in self.instances:
self.current_instance = instance
self.stop_instance()
@staticmethod
def start(instance: InstanceType) -> None:
service_name: str = instance.service_file_path.name
try:
cmd_sysctl_service(service_name, "start")
except CalledProcessError as e:
Logger.print_error(f"Error starting {service_name}: {e}")
raise
def find_instances(self) -> List[T]:
from utils.common import convert_camelcase_to_kebabcase
@staticmethod
def stop(instance: InstanceType) -> None:
name: str = instance.service_file_path.name
try:
cmd_sysctl_service(name, "stop")
except CalledProcessError as e:
Logger.print_error(f"Error stopping {name}: {e}")
raise
name = convert_camelcase_to_kebabcase(self.instance_type.__name__)
pattern = re.compile(f"^{name}(-[0-9a-zA-Z]+)?.service$")
excluded = self.instance_type.blacklist()
@staticmethod
def restart(instance: InstanceType) -> None:
name: str = instance.service_file_path.name
try:
cmd_sysctl_service(name, "restart")
except CalledProcessError as e:
Logger.print_error(f"Error restarting {name}: {e}")
raise
service_list = [
Path(SYSTEMD, service)
for service in SYSTEMD.iterdir()
if pattern.search(service.name)
and not any(s in service.name for s in excluded)
]
@staticmethod
def start_all(instances: List[InstanceType]) -> None:
for instance in instances:
InstanceManager.start(instance)
instance_list = [
self.instance_type(suffix=self._get_instance_suffix(name, service))
for service in service_list
]
@staticmethod
def stop_all(instances: List[InstanceType]) -> None:
for instance in instances:
InstanceManager.stop(instance)
return sorted(instance_list, key=lambda x: self._sort_instance_list(x.suffix))
@staticmethod
def restart_all(instances: List[InstanceType]) -> None:
for instance in instances:
InstanceManager.restart(instance)
def _get_instance_suffix(self, name: str, file_path: Path) -> str:
# to get the suffix of the instance, we remove the name of the instance from
# the file name, if the remaining part an empty string we return it
# otherwise there is and hyphen left, and we return the part after the hyphen
suffix = file_path.stem[len(name) :]
return suffix[1:] if suffix else ""
@staticmethod
def remove(instance: InstanceType) -> None:
from utils.fs_utils import run_remove_routines
from utils.sys_utils import remove_system_service
def _sort_instance_list(self, suffix: Union[int, str, None]):
if suffix is None:
return
elif suffix.isdigit():
return f"{int(suffix):04}"
else:
return suffix
try:
# remove the service file
service_file_path: Path = instance.service_file_path
if service_file_path is not None:
remove_system_service(service_file_path.name)
# then remove all the log files
if (
not instance.log_file_name
or not instance.base.log_dir
or not instance.base.log_dir.exists()
):
return
files = instance.base.log_dir.iterdir()
logs = [f for f in files if f.name.startswith(instance.log_file_name)]
for log in logs:
Logger.print_status(f"Remove '{log}'")
run_remove_routines(log)
except Exception as e:
Logger.print_error(f"Error removing service: {e}")
raise

View File

@@ -1,8 +0,0 @@
from enum import Enum, unique
@unique
class NameScheme(Enum):
SINGLE = "SINGLE"
INDEX = "INDEX"
CUSTOM = "CUSTOM"

View File

@@ -6,11 +6,13 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from enum import Enum
from typing import List
from utils.constants import (
from core.constants import (
COLOR_CYAN,
COLOR_GREEN,
COLOR_MAGENTA,
@@ -44,17 +46,17 @@ LINE_WIDTH = 53
class Logger:
@staticmethod
def info(msg):
def info(msg) -> None:
# log to kiauh.log
pass
@staticmethod
def warn(msg):
def warn(msg) -> None:
# log to kiauh.log
pass
@staticmethod
def error(msg):
def error(msg) -> None:
# log to kiauh.log
pass
@@ -64,7 +66,7 @@ class Logger:
print(f"{COLOR_WHITE}{start}{message}{RESET_FORMAT}", end=end)
@staticmethod
def print_ok(msg, prefix=True, start="", end="\n") -> None:
def print_ok(msg: str = "Success!", prefix=True, start="", end="\n") -> None:
message = f"[OK] {msg}" if prefix else msg
print(f"{COLOR_GREEN}{start}{message}{RESET_FORMAT}", end=end)
@@ -88,10 +90,10 @@ class Logger:
title: DialogType,
content: List[str],
center_content: bool = False,
custom_title: str = None,
custom_color: DialogCustomColor = None,
padding_top: int = 1,
padding_bottom: int = 1,
custom_title: str | None = None,
custom_color: DialogCustomColor | None = None,
margin_top: int = 0,
margin_bottom: int = 0,
) -> None:
"""
Prints a dialog with the given title and content.
@@ -104,8 +106,8 @@ class Logger:
:param center_content: Whether to center the content or not.
:param custom_title: A custom title for the dialog.
:param custom_color: A custom color for the dialog.
:param padding_top: The number of empty lines to print before the dialog.
:param padding_bottom: The number of empty lines to print after the dialog.
:param margin_top: The number of empty lines to print before the dialog.
:param margin_bottom: The number of empty lines to print after the dialog.
"""
dialog_color = Logger._get_dialog_color(title, custom_color)
dialog_title = Logger._get_dialog_title(title, custom_title)
@@ -114,26 +116,31 @@ class Logger:
top = Logger._format_top_border(dialog_color)
bottom = Logger._format_bottom_border()
print("\n" * padding_top)
print("\n" * margin_top)
print(
f"{top}{dialog_title_formatted}{dialog_content}{bottom}",
end="",
)
print("\n" * padding_bottom)
print("\n" * margin_bottom)
@staticmethod
def _get_dialog_title(title: DialogType, custom_title: str = None) -> str:
def _get_dialog_title(
title: DialogType, custom_title: str | None = None
) -> str | None:
if title == DialogType.CUSTOM and custom_title:
return f"[ {custom_title} ]"
return f"[ {title.value[0]} ]" if title.value[0] else None
@staticmethod
def _get_dialog_color(
title: DialogType, custom_color: DialogCustomColor = None
title: DialogType, custom_color: DialogCustomColor | None = None
) -> str:
if title == DialogType.CUSTOM and custom_color:
return str(custom_color.value)
return title.value[1] if title.value[1] else DialogCustomColor.WHITE.value
color: str = title.value[1] if title.value[1] else DialogCustomColor.WHITE.value
return color
@staticmethod
def _format_top_border(color: str) -> str:
@@ -146,7 +153,7 @@ class Logger:
)
@staticmethod
def _format_dialog_title(title: str) -> str:
def _format_dialog_title(title: str | None) -> str:
if title is not None:
return textwrap.dedent(f"""
{title:^{LINE_WIDTH}}

View File

@@ -6,10 +6,11 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Union
from typing import Any, Callable, Type
@dataclass
@@ -17,13 +18,14 @@ class Option:
"""
Represents a menu option.
:param method: Method that will be used to call the menu option
:param menu: Flag for singaling that another menu will be opened
:param opt_index: Can be used to pass the user input to the menu option
:param opt_data: Can be used to pass any additional data to the menu option
"""
method: Union[Callable, None] = None
menu: bool = False
def __repr__(self):
return f"Option(method={self.method.__name__}, opt_index={self.opt_index}, opt_data={self.opt_data})"
method: Type[Callable]
opt_index: str = ""
opt_data: Any = None

View File

@@ -6,9 +6,10 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.klipper import KLIPPER_DIR
from components.klipper.klipper import Klipper
@@ -21,37 +22,37 @@ from components.klipper_firmware.menus.klipper_flash_menu import (
)
from components.moonraker import MOONRAKER_DIR
from components.moonraker.moonraker import Moonraker
from core.constants import COLOR_YELLOW, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_YELLOW, RESET_FORMAT
from procedures.system import change_system_hostname
from utils.git_utils import rollback_repository
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class AdvancedMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self):
def set_options(self) -> None:
self.options = {
"1": Option(method=self.build, menu=True),
"2": Option(method=self.flash, menu=False),
"3": Option(method=self.build_flash, menu=False),
"4": Option(method=self.get_id, menu=False),
"5": Option(method=self.klipper_rollback, menu=True),
"6": Option(method=self.moonraker_rollback, menu=True),
"1": Option(method=self.build),
"2": Option(method=self.flash),
"3": Option(method=self.build_flash),
"4": Option(method=self.get_id),
"5": Option(method=self.klipper_rollback),
"6": Option(method=self.moonraker_rollback),
"7": Option(method=self.change_hostname),
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ Advanced Menu ] "
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
@@ -64,30 +65,34 @@ class AdvancedMenu(BaseMenu):
║ 1) [Build] │ 5) [Klipper] ║
║ 2) [Flash] │ 6) [Moonraker] ║
║ 3) [Build + Flash] │ ║
║ 4) [Get MCU ID] │
║ 4) [Get MCU ID] │ System:
║ │ 7) [Change hostname] ║
╟───────────────────────────┴───────────────────────────╢
"""
)[1:]
print(menu, end="")
def klipper_rollback(self, **kwargs):
def klipper_rollback(self, **kwargs) -> None:
rollback_repository(KLIPPER_DIR, Klipper)
def moonraker_rollback(self, **kwargs):
def moonraker_rollback(self, **kwargs) -> None:
rollback_repository(MOONRAKER_DIR, Moonraker)
def build(self, **kwargs):
def build(self, **kwargs) -> None:
KlipperBuildFirmwareMenu(previous_menu=self.__class__).run()
def flash(self, **kwargs):
def flash(self, **kwargs) -> None:
KlipperFlashMethodMenu(previous_menu=self.__class__).run()
def build_flash(self, **kwargs):
def build_flash(self, **kwargs) -> None:
KlipperBuildFirmwareMenu(previous_menu=KlipperFlashMethodMenu).run()
KlipperFlashMethodMenu(previous_menu=self.__class__).run()
def get_id(self, **kwargs):
def get_id(self, **kwargs) -> None:
KlipperSelectMcuConnectionMenu(
previous_menu=self.__class__,
standalone=True,
).run()
def change_hostname(self, **kwargs) -> None:
change_system_hostname()

View File

@@ -6,9 +6,10 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.klipper.klipper_utils import backup_klipper_dir
from components.klipperscreen.klipperscreen import backup_klipperscreen_dir
@@ -22,40 +23,38 @@ from components.webui_client.client_utils import (
)
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from core.constants import COLOR_CYAN, COLOR_YELLOW, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.common import backup_printer_config_dir
from utils.constants import COLOR_CYAN, COLOR_YELLOW, RESET_FORMAT
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class BackupMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self) -> None:
self.options = {
"1": Option(method=self.backup_klipper, menu=False),
"2": Option(method=self.backup_moonraker, menu=False),
"3": Option(method=self.backup_printer_config, menu=False),
"4": Option(method=self.backup_moonraker_db, menu=False),
"5": Option(method=self.backup_mainsail, menu=False),
"6": Option(method=self.backup_fluidd, menu=False),
"7": Option(method=self.backup_mainsail_config, menu=False),
"8": Option(method=self.backup_fluidd_config, menu=False),
"9": Option(method=self.backup_klipperscreen, menu=False),
"1": Option(method=self.backup_klipper),
"2": Option(method=self.backup_moonraker),
"3": Option(method=self.backup_printer_config),
"4": Option(method=self.backup_moonraker_db),
"5": Option(method=self.backup_mainsail),
"6": Option(method=self.backup_fluidd),
"7": Option(method=self.backup_mainsail_config),
"8": Option(method=self.backup_fluidd_config),
"9": Option(method=self.backup_klipperscreen),
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ Backup Menu ] "
line1 = f"{COLOR_YELLOW}INFO: Backups are located in '~/kiauh-backups'{RESET_FORMAT}"
color = COLOR_CYAN
@@ -81,29 +80,29 @@ class BackupMenu(BaseMenu):
)[1:]
print(menu, end="")
def backup_klipper(self, **kwargs):
def backup_klipper(self, **kwargs) -> None:
backup_klipper_dir()
def backup_moonraker(self, **kwargs):
def backup_moonraker(self, **kwargs) -> None:
backup_moonraker_dir()
def backup_printer_config(self, **kwargs):
def backup_printer_config(self, **kwargs) -> None:
backup_printer_config_dir()
def backup_moonraker_db(self, **kwargs):
def backup_moonraker_db(self, **kwargs) -> None:
backup_moonraker_db_dir()
def backup_mainsail(self, **kwargs):
def backup_mainsail(self, **kwargs) -> None:
backup_client_data(MainsailData())
def backup_fluidd(self, **kwargs):
def backup_fluidd(self, **kwargs) -> None:
backup_client_data(FluiddData())
def backup_mainsail_config(self, **kwargs):
def backup_mainsail_config(self, **kwargs) -> None:
backup_client_config_data(MainsailData())
def backup_fluidd_config(self, **kwargs):
def backup_fluidd_config(self, **kwargs) -> None:
backup_client_config_data(FluiddData())
def backup_klipperscreen(self, **kwargs):
def backup_klipperscreen(self, **kwargs) -> None:
backup_klipperscreen_dir()

View File

@@ -14,24 +14,25 @@ import sys
import textwrap
import traceback
from abc import abstractmethod
from typing import Dict, Optional, Type
from typing import Dict, Type
from core.menus import FooterType, Option
from utils.constants import (
from core.constants import (
COLOR_CYAN,
COLOR_GREEN,
COLOR_RED,
COLOR_YELLOW,
RESET_FORMAT,
)
from utils.logger import Logger
from core.logger import Logger
from core.menus import FooterType, Option
from utils.input_utils import get_selection_input
def clear():
subprocess.call("clear", shell=True)
def clear() -> None:
subprocess.call("clear -x", shell=True)
def print_header():
def print_header() -> None:
line1 = " [ KIAUH ] "
line2 = "Klipper Installation And Update Helper"
line3 = ""
@@ -49,7 +50,7 @@ def print_header():
print(header, end="")
def print_quit_footer():
def print_quit_footer() -> None:
text = "Q) Quit"
color = COLOR_RED
count = 62 - len(color) - len(RESET_FORMAT)
@@ -62,7 +63,7 @@ def print_quit_footer():
print(footer, end="")
def print_back_footer():
def print_back_footer() -> None:
text = "B) « Back"
color = COLOR_GREEN
count = 62 - len(color) - len(RESET_FORMAT)
@@ -75,7 +76,7 @@ def print_back_footer():
print(footer, end="")
def print_back_help_footer():
def print_back_help_footer() -> None:
text1 = "B) « Back"
text2 = "H) Help [?]"
color1 = COLOR_GREEN
@@ -90,7 +91,7 @@ def print_back_help_footer():
print(footer, end="")
def print_blank_footer():
def print_blank_footer() -> None:
print("╚═══════════════════════════════════════════════════════╝")
@@ -109,42 +110,46 @@ class BaseMenu(metaclass=PostInitCaller):
default_option: Option = None
input_label_txt: str = "Perform action"
header: bool = False
previous_menu: Type[BaseMenu] = None
help_menu: Type[BaseMenu] = None
previous_menu: Type[BaseMenu] | None = None
help_menu: Type[BaseMenu] | None = None
footer_type: FooterType = FooterType.BACK
def __init__(self, **kwargs):
def __init__(self, **kwargs) -> None:
if type(self) is BaseMenu:
raise NotImplementedError("BaseMenu cannot be instantiated directly.")
def __post_init__(self):
def __post_init__(self) -> None:
self.set_previous_menu(self.previous_menu)
self.set_options()
# conditionally add options based on footer type
if self.footer_type is FooterType.QUIT:
self.options["q"] = Option(method=self.__exit, menu=False)
self.options["q"] = Option(method=self.__exit)
if self.footer_type is FooterType.BACK:
self.options["b"] = Option(method=self.__go_back, menu=False)
self.options["b"] = Option(method=self.__go_back)
if self.footer_type is FooterType.BACK_HELP:
self.options["b"] = Option(method=self.__go_back, menu=False)
self.options["h"] = Option(method=self.__go_to_help, menu=False)
self.options["b"] = Option(method=self.__go_back)
self.options["h"] = Option(method=self.__go_to_help)
# if defined, add the default option to the options dict
if self.default_option is not None:
self.options[""] = self.default_option
def __go_back(self, **kwargs):
def __go_back(self, **kwargs) -> None:
if self.previous_menu is None:
return
self.previous_menu().run()
def __go_to_help(self, **kwargs):
self.help_menu(previous_menu=self).run()
def __go_to_help(self, **kwargs) -> None:
if self.help_menu is None:
return
self.help_menu(previous_menu=self.__class__).run()
def __exit(self, **kwargs):
def __exit(self, **kwargs) -> None:
Logger.print_ok("###### Happy printing!", False)
sys.exit(0)
@abstractmethod
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
raise NotImplementedError
@abstractmethod
@@ -173,43 +178,20 @@ class BaseMenu(metaclass=PostInitCaller):
self.print_menu()
self.print_footer()
def validate_user_input(self, usr_input: str) -> Option:
"""
Validate the user input and either return an Option, a string or None
:param usr_input: The user input in form of a string
:return: Option, str or None
"""
usr_input = usr_input.lower()
option = self.options.get(usr_input, Option(None, False, "", None))
# if option/usr_input is None/empty string, we execute the menus default option if specified
if (option is None or usr_input == "") and self.default_option is not None:
self.default_option.opt_index = usr_input
return self.default_option
# user selected a regular option
option.opt_index = usr_input
return option
def handle_user_input(self) -> Option:
"""Handle the user input, return the validated input or print an error."""
while True:
print(f"{COLOR_CYAN}###### {self.input_label_txt}: {RESET_FORMAT}", end="")
usr_input = input().lower()
validated_input = self.validate_user_input(usr_input)
if validated_input.method is not None:
return validated_input
else:
Logger.print_error("Invalid input!", False)
def run(self) -> None:
"""Start the menu lifecycle. When this function returns, the lifecycle of the menu ends."""
try:
self.display_menu()
option = self.handle_user_input()
option.method(opt_index=option.opt_index, opt_data=option.opt_data)
option = get_selection_input(self.input_label_txt, self.options)
selected_option: Option = self.options.get(option)
selected_option.method(
opt_index=selected_option.opt_index,
opt_data=selected_option.opt_data,
)
self.run()
except Exception as e:
Logger.print_error(
f"An unexpected error occured:\n{e}\n{traceback.format_exc()}"

View File

@@ -6,54 +6,53 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.crowsnest.crowsnest import install_crowsnest
from components.klipper import klipper_setup
from components.klipperscreen.klipperscreen import install_klipperscreen
from components.mobileraker.mobileraker import install_mobileraker
from components.moonraker import moonraker_setup
from components.octoeverywhere.octoeverywhere_setup import install_octoeverywhere
from components.webui_client import client_setup
from components.webui_client.client_config import client_config_setup
from components.webui_client.client_config.client_config_setup import (
install_client_config,
)
from components.webui_client.client_setup import install_client
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from components.webui_client.menus.client_install_menu import ClientInstallMenu
from core.constants import COLOR_GREEN, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_GREEN, RESET_FORMAT
from core.settings.kiauh_settings import KiauhSettings
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class InstallMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self) -> None:
self.options = {
"1": Option(method=self.install_klipper, menu=False),
"2": Option(method=self.install_moonraker, menu=False),
"3": Option(method=self.install_mainsail, menu=False),
"4": Option(method=self.install_fluidd, menu=False),
"5": Option(method=self.install_mainsail_config, menu=False),
"6": Option(method=self.install_fluidd_config, menu=False),
"7": Option(method=self.install_klipperscreen, menu=False),
"8": Option(method=self.install_mobileraker, menu=False),
"9": Option(method=self.install_crowsnest, menu=False),
"10": Option(method=self.install_octoeverywhere, menu=False),
"1": Option(method=self.install_klipper),
"2": Option(method=self.install_moonraker),
"3": Option(method=self.install_mainsail),
"4": Option(method=self.install_fluidd),
"5": Option(method=self.install_mainsail_config),
"6": Option(method=self.install_fluidd_config),
"7": Option(method=self.install_klipperscreen),
"8": Option(method=self.install_crowsnest),
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ Installation Menu ] "
color = COLOR_GREEN
count = 62 - len(color) - len(RESET_FORMAT)
@@ -65,46 +64,47 @@ class InstallMenu(BaseMenu):
║ Firmware & API: │ Touchscreen GUI: ║
║ 1) [Klipper] │ 7) [KlipperScreen] ║
║ 2) [Moonraker] │ ║
║ │ Android / iOS:
║ Webinterface: │ 8) [Mobileraker]
║ │ Webcam Streamer:
║ Webinterface: │ 8) [Crowsnest]
║ 3) [Mainsail] │ ║
║ 4) [Fluidd] │ Webcam Streamer:
║ │ 9) [Crowsnest] ║
║ Client-Config: │ ║
║ 5) [Mainsail-Config] │ Remote Access: ║
║ 6) [Fluidd-Config] │ 10) [OctoEverywhere] ║
║ 4) [Fluidd] │
║ │ ║
║ Client-Config: │ ║
║ 5) [Mainsail-Config] │ ║
║ 6) [Fluidd-Config] │ ║
╟───────────────────────────┴───────────────────────────╢
"""
)[1:]
print(menu, end="")
def install_klipper(self, **kwargs):
def install_klipper(self, **kwargs) -> None:
klipper_setup.install_klipper()
def install_moonraker(self, **kwargs):
def install_moonraker(self, **kwargs) -> None:
moonraker_setup.install_moonraker()
def install_mainsail(self, **kwargs):
client_setup.install_client(MainsailData())
def install_mainsail(self, **kwargs) -> None:
client: MainsailData = MainsailData()
if client.client_dir.exists():
ClientInstallMenu(client, self.__class__).run()
else:
install_client(client, settings=KiauhSettings())
def install_mainsail_config(self, **kwargs):
client_config_setup.install_client_config(MainsailData())
def install_mainsail_config(self, **kwargs) -> None:
install_client_config(MainsailData())
def install_fluidd(self, **kwargs):
client_setup.install_client(FluiddData())
def install_fluidd(self, **kwargs) -> None:
client: FluiddData = FluiddData()
if client.client_dir.exists():
ClientInstallMenu(client, self.__class__).run()
else:
install_client(client, settings=KiauhSettings())
def install_fluidd_config(self, **kwargs):
client_config_setup.install_client_config(FluiddData())
def install_fluidd_config(self, **kwargs) -> None:
install_client_config(FluiddData())
def install_klipperscreen(self, **kwargs):
def install_klipperscreen(self, **kwargs) -> None:
install_klipperscreen()
def install_mobileraker(self, **kwargs):
install_mobileraker()
def install_crowsnest(self, **kwargs):
def install_crowsnest(self, **kwargs) -> None:
install_crowsnest()
def install_octoeverywhere(self, **kwargs):
install_octoeverywhere()

View File

@@ -6,23 +6,32 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import sys
import textwrap
from typing import Optional, Type
from typing import Callable, Type
from components.crowsnest.crowsnest import get_crowsnest_status
from components.klipper.klipper_utils import get_klipper_status
from components.klipperscreen.klipperscreen import get_klipperscreen_status
from components.log_uploads.menus.log_upload_menu import LogUploadMenu
from components.mobileraker.mobileraker import get_mobileraker_status
from components.moonraker.moonraker_utils import get_moonraker_status
from components.octoeverywhere.octoeverywhere_setup import get_octoeverywhere_status
from components.webui_client.client_utils import (
get_client_status,
get_current_client_config,
)
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from core.constants import (
COLOR_CYAN,
COLOR_GREEN,
COLOR_MAGENTA,
COLOR_RED,
COLOR_YELLOW,
RESET_FORMAT,
)
from core.logger import Logger
from core.menus import FooterType
from core.menus.advanced_menu import AdvancedMenu
from core.menus.backup_menu import BackupMenu
@@ -31,51 +40,45 @@ from core.menus.install_menu import InstallMenu
from core.menus.remove_menu import RemoveMenu
from core.menus.settings_menu import SettingsMenu
from core.menus.update_menu import UpdateMenu
from core.types import ComponentStatus, StatusMap, StatusText
from extensions.extensions_menu import ExtensionsMenu
from utils.constants import (
COLOR_CYAN,
COLOR_GREEN,
COLOR_MAGENTA,
COLOR_RED,
COLOR_YELLOW,
RESET_FORMAT,
)
from utils.logger import Logger
from utils.types import ComponentStatus, StatusMap, StatusText
from utils.common import get_kiauh_version, trunc_string
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class MainMenu(BaseMenu):
def __init__(self):
def __init__(self) -> None:
super().__init__()
self.header = True
self.footer_type = FooterType.QUIT
self.header: bool = True
self.footer_type: FooterType = FooterType.QUIT
self.kl_status = self.kl_repo = self.mr_status = self.mr_repo = ""
self.ms_status = self.fl_status = self.ks_status = self.mb_status = ""
self.cn_status = self.cc_status = self.oe_status = ""
self.version = ""
self.kl_status, self.kl_owner, self.kl_repo = "", "", ""
self.mr_status, self.mr_owner, self.mr_repo = "", "", ""
self.ms_status, self.fl_status, self.ks_status = "", "", ""
self.cn_status, self.cc_status = "", ""
self._init_status()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
"""MainMenu does not have a previous menu"""
pass
def set_options(self) -> None:
self.options = {
"0": Option(method=self.log_upload_menu, menu=True),
"1": Option(method=self.install_menu, menu=True),
"2": Option(method=self.update_menu, menu=True),
"3": Option(method=self.remove_menu, menu=True),
"4": Option(method=self.advanced_menu, menu=True),
"5": Option(method=self.backup_menu, menu=True),
"e": Option(method=self.extension_menu, menu=True),
"s": Option(method=self.settings_menu, menu=True),
"0": Option(method=self.log_upload_menu),
"1": Option(method=self.install_menu),
"2": Option(method=self.update_menu),
"3": Option(method=self.remove_menu),
"4": Option(method=self.advanced_menu),
"5": Option(method=self.backup_menu),
"e": Option(method=self.extension_menu),
"s": Option(method=self.settings_menu),
}
def _init_status(self) -> None:
status_vars = ["kl", "mr", "ms", "fl", "ks", "mb", "cn", "oe"]
status_vars = ["kl", "mr", "ms", "fl", "ks", "cn"]
for var in status_vars:
setattr(
self,
@@ -84,21 +87,21 @@ class MainMenu(BaseMenu):
)
def _fetch_status(self) -> None:
self.version = get_kiauh_version()
self._get_component_status("kl", get_klipper_status)
self._get_component_status("mr", get_moonraker_status)
self._get_component_status("ms", get_client_status, MainsailData())
self._get_component_status("fl", get_client_status, FluiddData())
self.cc_status = get_current_client_config([MainsailData(), FluiddData()])
self._get_component_status("ks", get_klipperscreen_status)
self._get_component_status("mb", get_mobileraker_status)
self._get_component_status("cn", get_crowsnest_status)
self._get_component_status("oe", get_octoeverywhere_status)
self.cc_status = get_current_client_config()
def _get_component_status(self, name: str, status_fn: callable, *args) -> None:
def _get_component_status(self, name: str, status_fn: Callable, *args) -> None:
status_data: ComponentStatus = status_fn(*args)
code: int = status_data.status
status: StatusText = StatusMap[code]
repo: str = status_data.repo
owner: str = trunc_string(status_data.owner, 23)
repo: str = trunc_string(status_data.repo, 23)
instance_count: int = status_data.instances
count_txt: str = ""
@@ -106,6 +109,7 @@ class MainMenu(BaseMenu):
count_txt = f": {instance_count}"
setattr(self, f"{name}_status", self._format_by_code(code, status, count_txt))
setattr(self, f"{name}_owner", f"{COLOR_CYAN}{owner}{RESET_FORMAT}")
setattr(self, f"{name}_repo", f"{COLOR_CYAN}{repo}{RESET_FORMAT}")
def _format_by_code(self, code: int, status: str, count: str) -> str:
@@ -119,11 +123,11 @@ class MainMenu(BaseMenu):
return f"{color}{status}{count}{RESET_FORMAT}"
def print_menu(self):
def print_menu(self) -> None:
self._fetch_status()
header = " [ Main Menu ] "
footer1 = f"{COLOR_CYAN}KIAUH v6.0.0{RESET_FORMAT}"
footer1 = f"{COLOR_CYAN}{self.version}{RESET_FORMAT}"
footer2 = f"Changelog: {COLOR_MAGENTA}https://git.io/JnmlX{RESET_FORMAT}"
color = COLOR_CYAN
count = 62 - len(color) - len(RESET_FORMAT)
@@ -135,18 +139,18 @@ class MainMenu(BaseMenu):
{color}{header:~^{count}}{RESET_FORMAT}
╟──────────────────┬────────────────────────────────────╢
║ 0) [Log-Upload] │ Klipper: {self.kl_status:<{pad1}}
║ │ Repo: {self.kl_repo:<{pad1}}
║ 1) [Install] ├────────────────────────────────────╢
║ 2) [Update] │ Moonraker: {self.mr_status:<{pad1}}
║ 3) [Remove] │ Repo: {self.mr_repo:<{pad1}}
║ 4) [Advanced] ├────────────────────────────────────╢
║ 5) [Backup] │ Mainsail: {self.ms_status:<{pad2}}
║ │ Owner: {self.kl_owner:<{pad1}}
║ 1) [Install] │ Repo: {self.kl_repo:<{pad1}}
║ 2) [Update] ├────────────────────────────────────╢
║ 3) [Remove] │ Moonraker: {self.mr_status:<{pad1}}
║ 4) [Advanced] │ Owner: {self.mr_owner:<{pad1}}
║ 5) [Backup] │ Repo: {self.mr_repo:<{pad1}}
║ ├────────────────────────────────────╢
║ S) [Settings] │ Mainsail: {self.ms_status:<{pad2}}
║ │ Fluidd: {self.fl_status:<{pad2}}
S) [Settings] │ Client-Config: {self.cc_status:<{pad2}}
│ ║
Community: │ KlipperScreen: {self.ks_status:<{pad2}}
║ E) [Extensions] │ Mobileraker: {self.mb_status:<{pad2}}
║ │ OctoEverywhere: {self.oe_status:<{pad2}}
Community: │ Client-Config: {self.cc_status:<{pad2}}
E) [Extensions] │ ║
│ KlipperScreen: {self.ks_status:<{pad2}}
║ │ Crowsnest: {self.cn_status:<{pad2}}
╟──────────────────┼────────────────────────────────────╢
{footer1:^25}{footer2:^43}
@@ -155,30 +159,30 @@ class MainMenu(BaseMenu):
)[1:]
print(menu, end="")
def exit(self, **kwargs):
def exit(self, **kwargs) -> None:
Logger.print_ok("###### Happy printing!", False)
sys.exit(0)
def log_upload_menu(self, **kwargs):
def log_upload_menu(self, **kwargs) -> None:
LogUploadMenu().run()
def install_menu(self, **kwargs):
def install_menu(self, **kwargs) -> None:
InstallMenu(previous_menu=self.__class__).run()
def update_menu(self, **kwargs):
def update_menu(self, **kwargs) -> None:
UpdateMenu(previous_menu=self.__class__).run()
def remove_menu(self, **kwargs):
def remove_menu(self, **kwargs) -> None:
RemoveMenu(previous_menu=self.__class__).run()
def advanced_menu(self, **kwargs):
def advanced_menu(self, **kwargs) -> None:
AdvancedMenu(previous_menu=self.__class__).run()
def backup_menu(self, **kwargs):
def backup_menu(self, **kwargs) -> None:
BackupMenu(previous_menu=self.__class__).run()
def settings_menu(self, **kwargs):
def settings_menu(self, **kwargs) -> None:
SettingsMenu(previous_menu=self.__class__).run()
def extension_menu(self, **kwargs):
def extension_menu(self, **kwargs) -> None:
ExtensionsMenu(previous_menu=self.__class__).run()

View File

@@ -6,53 +6,48 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Type
from components.crowsnest.crowsnest import remove_crowsnest
from components.klipper.menus.klipper_remove_menu import KlipperRemoveMenu
from components.klipperscreen.klipperscreen import remove_klipperscreen
from components.mobileraker.mobileraker import remove_mobileraker
from components.moonraker.menus.moonraker_remove_menu import (
MoonrakerRemoveMenu,
)
from components.octoeverywhere.octoeverywhere_setup import remove_octoeverywhere
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from components.webui_client.menus.client_remove_menu import ClientRemoveMenu
from core.constants import COLOR_RED, RESET_FORMAT
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_RED, RESET_FORMAT
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class RemoveMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self):
def set_options(self) -> None:
self.options = {
"1": Option(method=self.remove_klipper, menu=True),
"2": Option(method=self.remove_moonraker, menu=True),
"3": Option(method=self.remove_mainsail, menu=True),
"4": Option(method=self.remove_fluidd, menu=True),
"5": Option(method=self.remove_klipperscreen, menu=True),
"6": Option(method=self.remove_mobileraker, menu=True),
"7": Option(method=self.remove_crowsnest, menu=True),
"8": Option(method=self.remove_octoeverywhere, menu=True),
"1": Option(method=self.remove_klipper),
"2": Option(method=self.remove_moonraker),
"3": Option(method=self.remove_mainsail),
"4": Option(method=self.remove_fluidd),
"5": Option(method=self.remove_klipperscreen),
"6": Option(method=self.remove_crowsnest),
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ Remove Menu ] "
color = COLOR_RED
count = 62 - len(color) - len(RESET_FORMAT)
@@ -63,41 +58,32 @@ class RemoveMenu(BaseMenu):
╟───────────────────────────────────────────────────────╢
║ INFO: Configurations and/or any backups will be kept! ║
╟───────────────────────────┬───────────────────────────╢
║ Firmware & API: │ Android / iOS:
║ 1) [Klipper] │ 6) [Mobileraker]
║ Firmware & API: │ Touchscreen GUI:
║ 1) [Klipper] │ 5) [KlipperScreen]
║ 2) [Moonraker] │ ║
║ │ Webcam Streamer: ║
║ Klipper Webinterface: │ 7) [Crowsnest] ║
║ Klipper Webinterface: │ 6) [Crowsnest] ║
║ 3) [Mainsail] │ ║
║ 4) [Fluidd] │ Remote Access:
║ │ 8) [OctoEverywhere] ║
║ Touchscreen GUI: │ ║
║ 5) [KlipperScreen] │ ║
║ 4) [Fluidd] │
╟───────────────────────────┴───────────────────────────╢
"""
)[1:]
print(menu, end="")
def remove_klipper(self, **kwargs):
def remove_klipper(self, **kwargs) -> None:
KlipperRemoveMenu(previous_menu=self.__class__).run()
def remove_moonraker(self, **kwargs):
def remove_moonraker(self, **kwargs) -> None:
MoonrakerRemoveMenu(previous_menu=self.__class__).run()
def remove_mainsail(self, **kwargs):
def remove_mainsail(self, **kwargs) -> None:
ClientRemoveMenu(previous_menu=self.__class__, client=MainsailData()).run()
def remove_fluidd(self, **kwargs):
def remove_fluidd(self, **kwargs) -> None:
ClientRemoveMenu(previous_menu=self.__class__, client=FluiddData()).run()
def remove_klipperscreen(self, **kwargs):
def remove_klipperscreen(self, **kwargs) -> None:
remove_klipperscreen()
def remove_mobileraker(self, **kwargs):
remove_mobileraker()
def remove_crowsnest(self, **kwargs):
def remove_crowsnest(self, **kwargs) -> None:
remove_crowsnest()
def remove_octoeverywhere(self, **kwargs):
remove_octoeverywhere()

View File

@@ -6,81 +6,86 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
import shutil
import textwrap
from pathlib import Path
from typing import Optional, Tuple, Type
from __future__ import annotations
from components.klipper import KLIPPER_DIR
from components.klipper.klipper import Klipper
from components.moonraker import MOONRAKER_DIR
from components.moonraker.moonraker import Moonraker
from core.instance_manager.instance_manager import InstanceManager
import textwrap
from typing import Literal, Tuple, Type
from components.klipper.klipper_utils import get_klipper_status
from components.moonraker.moonraker_utils import get_moonraker_status
from core.constants import COLOR_CYAN, COLOR_GREEN, RESET_FORMAT
from core.logger import DialogType, Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.settings.kiauh_settings import KiauhSettings
from utils.constants import COLOR_CYAN, COLOR_GREEN, RESET_FORMAT
from utils.git_utils import git_clone_wrapper
from core.settings.kiauh_settings import KiauhSettings, RepoSettings
from procedures.switch_repo import run_switch_repo_routine
from utils.input_utils import get_confirm, get_string_input
from utils.logger import DialogType, Logger
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class SettingsMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.klipper_repo = None
self.moonraker_repo = None
self.mainsail_unstable = None
self.fluidd_unstable = None
self.auto_backups_enabled = None
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.klipper_status = get_klipper_status()
self.moonraker_status = get_moonraker_status()
self.mainsail_unstable: bool | None = None
self.fluidd_unstable: bool | None = None
self.auto_backups_enabled: bool | None = None
self._load_settings()
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self) -> None:
self.options = {
"1": Option(method=self.set_klipper_repo, menu=True),
"2": Option(method=self.set_moonraker_repo, menu=True),
"3": Option(method=self.toggle_mainsail_release, menu=True),
"4": Option(method=self.toggle_fluidd_release, menu=False),
"5": Option(method=self.toggle_backup_before_update, menu=False),
"1": Option(method=self.set_klipper_repo),
"2": Option(method=self.set_moonraker_repo),
"3": Option(method=self.toggle_mainsail_release),
"4": Option(method=self.toggle_fluidd_release),
"5": Option(method=self.toggle_backup_before_update),
}
def print_menu(self):
def print_menu(self) -> None:
header = " [ KIAUH Settings ] "
color = COLOR_CYAN
count = 62 - len(color) - len(RESET_FORMAT)
checked = f"[{COLOR_GREEN}x{RESET_FORMAT}]"
color, rst = COLOR_CYAN, RESET_FORMAT
count = 62 - len(color) - len(rst)
checked = f"[{COLOR_GREEN}x{rst}]"
unchecked = "[ ]"
kl_repo: str = f"{color}{self.klipper_status.repo}{rst}"
kl_branch: str = f"{color}{self.klipper_status.branch}{rst}"
kl_owner: str = f"{color}{self.klipper_status.owner}{rst}"
mr_repo: str = f"{color}{self.moonraker_status.repo}{rst}"
mr_branch: str = f"{color}{self.moonraker_status.branch}{rst}"
mr_owner: str = f"{color}{self.moonraker_status.owner}{rst}"
o1 = checked if self.mainsail_unstable else unchecked
o2 = checked if self.fluidd_unstable else unchecked
o3 = checked if self.auto_backups_enabled else unchecked
menu = textwrap.dedent(
f"""
╔═══════════════════════════════════════════════════════╗
{color}{header:~^{count}}{RESET_FORMAT}
{color}{header:~^{count}}{rst}
╟───────────────────────────────────────────────────────╢
║ Klipper source repository:
{self.klipper_repo:<67}
Moonraker source repository:
║ ● {self.moonraker_repo:<67}
Install unstable Webinterface releases:
║ Klipper:
● Repo: {kl_repo:51}
● Owner: {kl_owner:51}
● Branch: {kl_branch:51}
╟───────────────────────────────────────────────────────╢
Moonraker:
● Repo: {mr_repo:51}
║ ● Owner: {mr_owner:51}
║ ● Branch: {mr_branch:51}
╟───────────────────────────────────────────────────────╢
║ Install unstable releases: ║
{o1} Mainsail ║
{o2} Fluidd ║
║ ║
╟───────────────────────────────────────────────────────╢
║ Auto-Backup: ║
{o3} Automatic backup before update ║
║ ║
╟───────────────────────────────────────────────────────╢
║ 1) Set Klipper source repository ║
║ 2) Set Moonraker source repository ║
@@ -94,32 +99,23 @@ class SettingsMenu(BaseMenu):
)[1:]
print(menu, end="")
def _load_settings(self):
def _load_settings(self) -> None:
self.settings = KiauhSettings()
self._format_repo_str("klipper")
self._format_repo_str("moonraker")
self.auto_backups_enabled = self.settings.kiauh.backup_before_update
self.mainsail_unstable = self.settings.mainsail.unstable_releases
self.fluidd_unstable = self.settings.fluidd.unstable_releases
def _format_repo_str(self, repo_name: str) -> None:
repo = self.settings.get(repo_name, "repo_url")
repo = f"{'/'.join(repo.rsplit('/', 2)[-2:])}"
branch = self.settings.get(repo_name, "branch")
branch = f"({COLOR_CYAN}@ {branch}{RESET_FORMAT})"
setattr(self, f"{repo_name}_repo", f"{COLOR_CYAN}{repo}{RESET_FORMAT} {branch}")
def _gather_input(self) -> Tuple[str, str]:
Logger.print_dialog(
DialogType.ATTENTION,
[
"There is no input validation in place! Make sure your"
" input is valid and has no typos! For any change to"
" take effect, the repository must be cloned again. "
"Make sure you don't have any ongoing prints running, "
"as the services will be restarted!"
"There is no input validation in place! Make sure your the input is "
"valid and has no typos or invalid characters! For the change to take "
"effect, the new repository will be cloned. A backup of the old "
"repository will be created.",
"\n\n",
"Make sure you don't have any ongoing prints running, as the services "
"will be restarted during this process! You will loose any ongoing print!",
],
)
repo = get_string_input(
@@ -133,7 +129,7 @@ class SettingsMenu(BaseMenu):
return repo, branch
def _set_repo(self, repo_name: str):
def _set_repo(self, repo_name: Literal["klipper", "moonraker"]) -> None:
repo_url, branch = self._gather_input()
display_name = repo_name.capitalize()
Logger.print_dialog(
@@ -147,10 +143,13 @@ class SettingsMenu(BaseMenu):
)
if get_confirm("Apply changes?", allow_go_back=True):
self.settings.set(repo_name, "repo_url", repo_url)
self.settings.set(repo_name, "branch", branch)
repo: RepoSettings = self.settings[repo_name]
repo.repo_url = repo_url
repo.branch = branch
self.settings.save()
self._load_settings()
Logger.print_ok("Changes saved!")
else:
Logger.print_info(
@@ -160,49 +159,28 @@ class SettingsMenu(BaseMenu):
Logger.print_status(f"Switching to {display_name}'s new source repository ...")
self._switch_repo(repo_name)
Logger.print_ok(f"Switched to {repo_url} at branch {branch}!")
def _switch_repo(self, name: str) -> None:
target_dir: Path
if name == "klipper":
target_dir = KLIPPER_DIR
_type = Klipper
elif name == "moonraker":
target_dir = MOONRAKER_DIR
_type = Moonraker
else:
Logger.print_error("Invalid repository name!")
return
def _switch_repo(self, name: Literal["klipper", "moonraker"]) -> None:
repo: RepoSettings = self.settings[name]
run_switch_repo_routine(name, repo)
if target_dir.exists():
shutil.rmtree(target_dir)
im = InstanceManager(_type)
im.stop_all_instance()
repo = self.settings.get(name, "repo_url")
branch = self.settings.get(name, "branch")
git_clone_wrapper(repo, target_dir, branch)
im.start_all_instance()
def set_klipper_repo(self, **kwargs):
def set_klipper_repo(self, **kwargs) -> None:
self._set_repo("klipper")
def set_moonraker_repo(self, **kwargs):
def set_moonraker_repo(self, **kwargs) -> None:
self._set_repo("moonraker")
def toggle_mainsail_release(self, **kwargs):
def toggle_mainsail_release(self, **kwargs) -> None:
self.mainsail_unstable = not self.mainsail_unstable
self.settings.mainsail.unstable_releases = self.mainsail_unstable
self.settings.save()
def toggle_fluidd_release(self, **kwargs):
def toggle_fluidd_release(self, **kwargs) -> None:
self.fluidd_unstable = not self.fluidd_unstable
self.settings.fluidd.unstable_releases = self.fluidd_unstable
self.settings.save()
def toggle_backup_before_update(self, **kwargs):
def toggle_backup_before_update(self, **kwargs) -> None:
self.auto_backups_enabled = not self.auto_backups_enabled
self.settings.kiauh.backup_before_update = self.auto_backups_enabled
self.settings.save()

View File

@@ -6,8 +6,10 @@
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import textwrap
from typing import Optional, Type
from typing import Callable, List, Type
from components.crowsnest.crowsnest import get_crowsnest_status, update_crowsnest
from components.klipper.klipper_setup import update_klipper
@@ -18,16 +20,8 @@ from components.klipperscreen.klipperscreen import (
get_klipperscreen_status,
update_klipperscreen,
)
from components.mobileraker.mobileraker import (
get_mobileraker_status,
update_mobileraker,
)
from components.moonraker.moonraker_setup import update_moonraker
from components.moonraker.moonraker_utils import get_moonraker_status
from components.octoeverywhere.octoeverywhere_setup import (
get_octoeverywhere_status,
update_octoeverywhere,
)
from components.webui_client.client_config.client_config_setup import (
update_client_config,
)
@@ -38,25 +32,34 @@ from components.webui_client.client_utils import (
)
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from core.menus import Option
from core.menus.base_menu import BaseMenu
from utils.constants import (
from core.constants import (
COLOR_GREEN,
COLOR_RED,
COLOR_YELLOW,
RESET_FORMAT,
)
from utils.logger import Logger
from utils.spinner import Spinner
from utils.types import ComponentStatus
from core.logger import DialogType, Logger
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.spinner import Spinner
from core.types import ComponentStatus
from utils.input_utils import get_confirm
from utils.sys_utils import (
get_upgradable_packages,
update_system_package_lists,
upgrade_system_packages,
)
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class UpdateMenu(BaseMenu):
def __init__(self, previous_menu: Optional[Type[BaseMenu]] = None):
def __init__(self, previous_menu: Type[BaseMenu] | None = None) -> None:
super().__init__()
self.previous_menu = previous_menu
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.packages: List[str] = []
self.package_count: int = 0
self.klipper_local = self.klipper_remote = ""
self.moonraker_local = self.moonraker_remote = ""
@@ -65,50 +68,82 @@ class UpdateMenu(BaseMenu):
self.fluidd_local = self.fluidd_remote = ""
self.fluidd_config_local = self.fluidd_config_remote = ""
self.klipperscreen_local = self.klipperscreen_remote = ""
self.mobileraker_local = self.mobileraker_remote = ""
self.crowsnest_local = self.crowsnest_remote = ""
self.octoeverywhere_local = self.octoeverywhere_remote = ""
self.mainsail_data = MainsailData()
self.fluidd_data = FluiddData()
self.status_data = {
"klipper": {"installed": False, "local": None, "remote": None},
"moonraker": {"installed": False, "local": None, "remote": None},
"mainsail": {"installed": False, "local": None, "remote": None},
"mainsail_config": {"installed": False, "local": None, "remote": None},
"fluidd": {"installed": False, "local": None, "remote": None},
"fluidd_config": {"installed": False, "local": None, "remote": None},
"mobileraker": {"installed": False, "local": None, "remote": None},
"klipperscreen": {"installed": False, "local": None, "remote": None},
"crowsnest": {"installed": False, "local": None, "remote": None},
"octoeverywhere": {"installed": False, "local": None, "remote": None},
"klipper": {
"display_name": "Klipper",
"installed": False,
"local": None,
"remote": None,
},
"moonraker": {
"display_name": "Moonraker",
"installed": False,
"local": None,
"remote": None,
},
"mainsail": {
"display_name": "Mainsail",
"installed": False,
"local": None,
"remote": None,
},
"mainsail_config": {
"display_name": "Mainsail-Config",
"installed": False,
"local": None,
"remote": None,
},
"fluidd": {
"display_name": "Fluidd",
"installed": False,
"local": None,
"remote": None,
},
"fluidd_config": {
"display_name": "Fluidd-Config",
"installed": False,
"local": None,
"remote": None,
},
"klipperscreen": {
"display_name": "KlipperScreen",
"installed": False,
"local": None,
"remote": None,
},
"crowsnest": {
"display_name": "Crowsnest",
"installed": False,
"local": None,
"remote": None,
},
}
def set_previous_menu(self, previous_menu: Optional[Type[BaseMenu]]) -> None:
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu
self.previous_menu: Type[BaseMenu] = (
previous_menu if previous_menu is not None else MainMenu
)
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
def set_options(self) -> None:
self.options = {
"a": Option(self.update_all, menu=False),
"1": Option(self.update_klipper, menu=False),
"2": Option(self.update_moonraker, menu=False),
"3": Option(self.update_mainsail, menu=False),
"4": Option(self.update_fluidd, menu=False),
"5": Option(self.update_mainsail_config, menu=False),
"6": Option(self.update_fluidd_config, menu=False),
"7": Option(self.update_klipperscreen, menu=False),
"8": Option(self.update_mobileraker, menu=False),
"9": Option(self.update_crowsnest, menu=False),
"10": Option(self.update_octoeverywhere, menu=False),
"11": Option(self.upgrade_system_packages, menu=False),
"a": Option(self.update_all),
"1": Option(self.update_klipper),
"2": Option(self.update_moonraker),
"3": Option(self.update_mainsail),
"4": Option(self.update_fluidd),
"5": Option(self.update_mainsail_config),
"6": Option(self.update_fluidd_config),
"7": Option(self.update_klipperscreen),
"8": Option(self.update_crowsnest),
"9": Option(self.upgrade_system_packages),
}
def print_menu(self):
spinner = Spinner()
def print_menu(self) -> None:
spinner = Spinner("Loading update menu, please wait", color="green")
spinner.start()
self._fetch_update_status()
@@ -118,6 +153,15 @@ class UpdateMenu(BaseMenu):
header = " [ Update Menu ] "
color = COLOR_GREEN
count = 62 - len(color) - len(RESET_FORMAT)
sysupgrades: str = "No upgrades available."
padding = 29
if self.package_count > 0:
sysupgrades = (
f"{COLOR_GREEN}{self.package_count} upgrades available!{RESET_FORMAT}"
)
padding = 38
menu = textwrap.dedent(
f"""
╔═══════════════════════════════════════════════════════╗
@@ -139,62 +183,70 @@ class UpdateMenu(BaseMenu):
║ │ │ ║
║ Other: ├───────────────┼───────────────╢
║ 7) KlipperScreen │ {self.klipperscreen_local:<22}{self.klipperscreen_remote:<22}
║ 8) Mobileraker{self.mobileraker_local:<22}{self.mobileraker_remote:<22}
║ 9) Crowsnest │ {self.crowsnest_local:<22}{self.crowsnest_remote:<22}
║ 10) OctoEverywhere │ {self.octoeverywhere_local:<22}{self.octoeverywhere_remote:<22}
║ 8) Crowsnest {self.crowsnest_local:<22}{self.crowsnest_remote:<22}
║ ├───────────────┴───────────────╢
11) System │
9) System │ {sysupgrades:^{padding}}
╟───────────────────────┴───────────────────────────────╢
"""
)[1:]
print(menu, end="")
def update_all(self, **kwargs):
print("update_all")
def update_all(self, **kwargs) -> None:
Logger.print_status("Updating all components ...")
self.update_klipper()
self.update_moonraker()
self.update_mainsail()
self.update_mainsail_config()
self.update_fluidd()
self.update_fluidd_config()
self.update_klipperscreen()
self.update_crowsnest()
self.upgrade_system_packages()
def update_klipper(self, **kwargs):
if self._check_is_installed("klipper"):
update_klipper()
def update_klipper(self, **kwargs) -> None:
self._run_update_routine("klipper", update_klipper)
def update_moonraker(self, **kwargs):
if self._check_is_installed("moonraker"):
update_moonraker()
def update_moonraker(self, **kwargs) -> None:
self._run_update_routine("moonraker", update_moonraker)
def update_mainsail(self, **kwargs):
if self._check_is_installed("mainsail"):
update_client(self.mainsail_data)
def update_mainsail(self, **kwargs) -> None:
self._run_update_routine(
"mainsail",
update_client,
self.mainsail_data,
)
def update_mainsail_config(self, **kwargs):
if self._check_is_installed("mainsail_config"):
update_client_config(self.mainsail_data)
def update_mainsail_config(self, **kwargs) -> None:
self._run_update_routine(
"mainsail_config",
update_client_config,
self.mainsail_data,
)
def update_fluidd(self, **kwargs):
if self._check_is_installed("fluidd"):
update_client(self.fluidd_data)
def update_fluidd(self, **kwargs) -> None:
self._run_update_routine(
"fluidd",
update_client,
self.fluidd_data,
)
def update_fluidd_config(self, **kwargs):
if self._check_is_installed("fluidd_config"):
update_client_config(self.fluidd_data)
def update_fluidd_config(self, **kwargs) -> None:
self._run_update_routine(
"fluidd_config",
update_client_config,
self.fluidd_data,
)
def update_klipperscreen(self, **kwargs):
if self._check_is_installed("klipperscreen"):
update_klipperscreen()
def update_klipperscreen(self, **kwargs) -> None:
self._run_update_routine("klipperscreen", update_klipperscreen)
def update_mobileraker(self, **kwargs):
if self._check_is_installed("mobileraker"):
update_mobileraker()
def update_crowsnest(self, **kwargs) -> None:
self._run_update_routine("crowsnest", update_crowsnest)
def update_crowsnest(self, **kwargs):
if self._check_is_installed("crowsnest"):
update_crowsnest()
def upgrade_system_packages(self, **kwargs) -> None:
self._run_system_updates()
def update_octoeverywhere(self, **kwargs):
if self._check_is_installed("octoeverywhere"):
update_octoeverywhere()
def upgrade_system_packages(self, **kwargs): ...
def _fetch_update_status(self):
def _fetch_update_status(self) -> None:
self._set_status_data("klipper", get_klipper_status)
self._set_status_data("moonraker", get_moonraker_status)
self._set_status_data("mainsail", get_client_status, self.mainsail_data, True)
@@ -206,9 +258,11 @@ class UpdateMenu(BaseMenu):
"fluidd_config", get_client_config_status, self.fluidd_data
)
self._set_status_data("klipperscreen", get_klipperscreen_status)
self._set_status_data("mobileraker", get_mobileraker_status)
self._set_status_data("crowsnest", get_crowsnest_status)
self._set_status_data("octoeverywhere", get_octoeverywhere_status)
update_system_package_lists(silent=True)
self.packages = get_upgradable_packages()
self.package_count = len(self.packages)
def _format_local_status(self, local_version, remote_version) -> str:
color = COLOR_RED
@@ -221,7 +275,7 @@ class UpdateMenu(BaseMenu):
return f"{color}{local_version or '-'}{RESET_FORMAT}"
def _set_status_data(self, name: str, status_fn: callable, *args) -> None:
def _set_status_data(self, name: str, status_fn: Callable, *args) -> None:
comp_status: ComponentStatus = status_fn(*args)
self.status_data[name]["installed"] = True if comp_status.status == 2 else False
@@ -242,7 +296,41 @@ class UpdateMenu(BaseMenu):
setattr(self, f"{name}_remote", remote_txt)
def _check_is_installed(self, name: str) -> bool:
if not self.status_data[name]["installed"]:
Logger.print_info(f"{name.capitalize()} is not installed! Skipped ...")
return False
return True
return self.status_data[name]["installed"]
def _is_update_available(self, name: str) -> bool:
return self.status_data[name]["local"] != self.status_data[name]["remote"]
def _run_update_routine(self, name: str, update_fn: Callable, *args) -> None:
display_name = self.status_data[name]["display_name"]
is_installed = self._check_is_installed(name)
is_update_available = self._is_update_available(name)
if not is_installed:
Logger.print_info(f"{display_name} is not installed! Skipped ...")
return
elif not is_update_available:
Logger.print_info(f"{display_name} is already up to date! Skipped ...")
return
update_fn(*args)
def _run_system_updates(self) -> None:
if not self.packages:
Logger.print_info("No system upgrades available!")
return
try:
pkgs: str = ", ".join(self.packages)
Logger.print_dialog(
DialogType.CUSTOM,
["The following packages will be upgraded:", "\n\n", pkgs],
custom_title="UPGRADABLE SYSTEM UPDATES",
)
if not get_confirm("Continue?"):
return
Logger.print_status("Upgrading system packages ...")
upgrade_system_packages(self.packages)
except Exception as e:
Logger.print_error(f"Error upgrading system packages:\n{e}")
raise

View File

@@ -8,14 +8,15 @@
# ======================================================================= #
from __future__ import annotations
from typing import Union
from dataclasses import dataclass, field
from typing import Any
from core.logger import DialogType, Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
NoOptionError,
NoSectionError,
SimpleConfigParser,
)
from utils.logger import DialogType, Logger
from utils.sys_utils import kill
from kiauh import PROJECT_ROOT
@@ -24,33 +25,21 @@ DEFAULT_CFG = PROJECT_ROOT.joinpath("default.kiauh.cfg")
CUSTOM_CFG = PROJECT_ROOT.joinpath("kiauh.cfg")
@dataclass
class AppSettings:
def __init__(self) -> None:
self.backup_before_update = None
backup_before_update: bool | None = field(default=None)
class KlipperSettings:
def __init__(self) -> None:
self.repo_url = None
self.branch = None
@dataclass
class RepoSettings:
repo_url: str | None = field(default=None)
branch: str | None = field(default=None)
class MoonrakerSettings:
def __init__(self) -> None:
self.repo_url = None
self.branch = None
class MainsailSettings:
def __init__(self) -> None:
self.port = None
self.unstable_releases = None
class FluiddSettings:
def __init__(self) -> None:
self.port = None
self.unstable_releases = None
@dataclass
class WebUiSettings:
port: str | None = field(default=None)
unstable_releases: bool | None = field(default=None)
# noinspection PyUnusedLocal
@@ -61,36 +50,37 @@ class KiauhSettings:
def __new__(cls, *args, **kwargs) -> "KiauhSettings":
if cls._instance is None:
cls._instance = super(KiauhSettings, cls).__new__(cls, *args, **kwargs)
cls._instance.__initialized = False
return cls._instance
def __repr__(self) -> str:
return (
f"KiauhSettings(kiauh={self.kiauh}, klipper={self.klipper},"
f" moonraker={self.moonraker}, mainsail={self.mainsail},"
f" fluidd={self.fluidd})"
)
def __getitem__(self, item: str) -> Any:
return getattr(self, item)
def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized:
return
self.__initialized = True
self.config = SimpleConfigParser()
self.kiauh = AppSettings()
self.klipper = KlipperSettings()
self.moonraker = MoonrakerSettings()
self.mainsail = MainsailSettings()
self.fluidd = FluiddSettings()
self.kiauh.backup_before_update = None
self.klipper.repo_url = None
self.klipper.branch = None
self.moonraker.repo_url = None
self.moonraker.branch = None
self.mainsail.port = None
self.mainsail.unstable_releases = None
self.fluidd.port = None
self.fluidd.unstable_releases = None
self.klipper = RepoSettings()
self.moonraker = RepoSettings()
self.mainsail = WebUiSettings()
self.fluidd = WebUiSettings()
self._load_config()
def get(self, section: str, option: str) -> Union[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 strings.
Prefer direct access to the properties, as it is usually safer!
Get a value from the settings state by providing the section and option name as
strings. Prefer direct access to the properties, as it is usually safer!
:param section: The section name as string.
:param option: The option name as string.
:return: The value of the option as string, int or bool.
@@ -99,27 +89,13 @@ class KiauhSettings:
try:
section = getattr(self, section)
value = getattr(section, option)
return value
except AttributeError:
raise
def set(self, section: str, option: str, value: Union[str, int, bool]) -> None:
"""
Set a value in the settings state by providing the section and option name as strings.
Prefer direct access to the properties, as it is usually safer!
:param section: The section name as string.
:param option: The option name as string.
:param value: The value to set as string, int or bool.
"""
try:
section = getattr(self, section)
section.option = value
return value # type: ignore
except AttributeError:
raise
def save(self) -> None:
self._set_config_options()
self.config.write(CUSTOM_CFG)
self._set_config_options_state()
self.config.write_file(CUSTOM_CFG)
self._load_config()
def _load_config(self) -> None:
@@ -127,10 +103,10 @@ class KiauhSettings:
self._kill()
cfg = CUSTOM_CFG if CUSTOM_CFG.exists() else DEFAULT_CFG
self.config.read(cfg)
self.config.read_file(cfg)
self._validate_cfg()
self._read_settings()
self._apply_settings_from_file()
def _validate_cfg(self) -> None:
try:
@@ -160,7 +136,7 @@ class KiauhSettings:
def _validate_bool(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
bool(self.config.getboolean(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)
@@ -168,18 +144,18 @@ class KiauhSettings:
def _validate_str(self, section: str, option: str) -> None:
self._v_section, self._v_option = (section, option)
v = self.config.get(section, option)
v = self.config.getval(section, option)
if v.isdigit() or v.lower() == "true" or v.lower() == "false":
raise ValueError
def _read_settings(self):
def _apply_settings_from_file(self) -> None:
self.kiauh.backup_before_update = self.config.getboolean(
"kiauh", "backup_before_update"
)
self.klipper.repo_url = self.config.get("klipper", "repo_url")
self.klipper.branch = self.config.get("klipper", "branch")
self.moonraker.repo_url = self.config.get("moonraker", "repo_url")
self.moonraker.branch = self.config.get("moonraker", "branch")
self.klipper.repo_url = self.config.getval("klipper", "repo_url")
self.klipper.branch = self.config.getval("klipper", "branch")
self.moonraker.repo_url = self.config.getval("moonraker", "repo_url")
self.moonraker.branch = self.config.getval("moonraker", "branch")
self.mainsail.port = self.config.getint("mainsail", "port")
self.mainsail.unstable_releases = self.config.getboolean(
"mainsail", "unstable_releases"
@@ -189,24 +165,24 @@ class KiauhSettings:
"fluidd", "unstable_releases"
)
def _set_config_options(self):
self.config.set(
def _set_config_options_state(self) -> None:
self.config.set_option(
"kiauh",
"backup_before_update",
str(self.kiauh.backup_before_update),
)
self.config.set("klipper", "repo_url", self.klipper.repo_url)
self.config.set("klipper", "branch", self.klipper.branch)
self.config.set("moonraker", "repo_url", self.moonraker.repo_url)
self.config.set("moonraker", "branch", self.moonraker.branch)
self.config.set("mainsail", "port", str(self.mainsail.port))
self.config.set(
self.config.set_option("klipper", "repo_url", self.klipper.repo_url)
self.config.set_option("klipper", "branch", self.klipper.branch)
self.config.set_option("moonraker", "repo_url", self.moonraker.repo_url)
self.config.set_option("moonraker", "branch", self.moonraker.branch)
self.config.set_option("mainsail", "port", str(self.mainsail.port))
self.config.set_option(
"mainsail",
"unstable_releases",
str(self.mainsail.unstable_releases),
)
self.config.set("fluidd", "port", str(self.fluidd.port))
self.config.set(
self.config.set_option("fluidd", "port", str(self.fluidd.port))
self.config.set_option(
"fluidd", "unstable_releases", str(self.fluidd.unstable_releases)
)

61
kiauh/core/spinner.py Normal file
View File

@@ -0,0 +1,61 @@
import sys
import threading
import time
from typing import List, Literal
from core.constants import (
COLOR_GREEN,
COLOR_RED,
COLOR_WHITE,
COLOR_YELLOW,
RESET_FORMAT,
)
SpinnerColor = Literal["white", "red", "green", "yellow"]
class Spinner:
def __init__(
self,
message: str = "Loading",
color: SpinnerColor = "white",
interval: float = 0.2,
) -> None:
self.message = f"{message} ..."
self.interval = interval
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._animate)
self._color = ""
self._set_color(color)
def _animate(self) -> None:
animation: List[str] = ["", "", "", "", "", "", "", "", "", ""]
while not self._stop_event.is_set():
for char in animation:
sys.stdout.write(f"\r{self._color}{char}{RESET_FORMAT} {self.message}")
sys.stdout.flush()
time.sleep(self.interval)
if self._stop_event.is_set():
break
sys.stdout.write("\r" + " " * (len(self.message) + 1) + "\r")
sys.stdout.flush()
def _set_color(self, color: SpinnerColor) -> None:
if color == "white":
self._color = COLOR_WHITE
elif color == "red":
self._color = COLOR_RED
elif color == "green":
self._color = COLOR_GREEN
elif color == "yellow":
self._color = COLOR_YELLOW
def start(self) -> None:
self._stop_event.clear()
if not self._thread.is_alive():
self._thread = threading.Thread(target=self._animate)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join()

View File

@@ -0,0 +1,62 @@
# ======================================================================= #
# Copyright (C) 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# https://github.com/dw-0/simple-config-parser #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
import re
# definition of section line:
# - then line MUST start with an opening square bracket - it is the first section marker
# - the section marker MUST be followed by at least one character - it is the section name
# - the section name MUST be followed by a closing square bracket - it is the second section marker
# - the second section marker MAY be followed by any amount of whitespace characters
# - the second section marker MAY be followed by a # or ; - it is the comment marker
# - the inline comment MAY be of any length and character
SECTION_RE = re.compile(r"^\[(\S.*\S|\S)]\s*([#;].*)?$")
# definition of option line:
# - the line MUST start with a word - it is the option name
# - the option name MUST be followed by a colon or an equal sign - it is the separator
# - the separator MUST be followed by a value
# - the separator MAY have any amount of leading or trailing whitespaces
# - the separator MUST NOT be directly followed by a colon or equal sign
# - the value MAY be of any length and character
# - the value MAY contain any amount of trailing whitespaces
# - the value MAY be followed by a # or ; - it is the comment marker
# - the inline comment MAY be of any length and character
OPTION_RE = re.compile(r"^([^;#:=\s]+)\s?[:=]\s*([^;#:=\s][^;#]*?)\s*([#;].*)?$")
# definition of options block start line:
# - the line MUST start with a word - it is the option name
# - the option name MUST be followed by a colon or an equal sign - it is the separator
# - the separator MUST NOT be followed by a value
# - the separator MAY have any amount of leading or trailing whitespaces
# - the separator MUST NOT be directly followed by a colon or equal sign
# - the separator MAY be followed by a # or ; - it is the comment marker
# - the inline comment MAY be of any length and character
OPTIONS_BLOCK_START_RE = re.compile(r"^([^;#:=\s]+)\s*[:=]\s*([#;].*)?$")
# definition of comment line:
# - the line MAY start with any amount of whitespace characters
# - the line MUST contain a # or ; - it is the comment marker
# - the comment marker MAY be followed by any amount of whitespace characters
# - the comment MAY be of any length and character
LINE_COMMENT_RE = re.compile(r"^\s*[#;].*")
# definition of empty line:
# - the line MUST contain only whitespace characters
EMPTY_LINE_RE = re.compile(r"^\s*$")
BOOLEAN_STATES = {
"1": True,
"yes": True,
"true": True,
"on": True,
"0": False,
"no": False,
"false": False,
"off": False,
}
HEADER_IDENT = "#_header"

View File

@@ -1,5 +1,5 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> #
# Copyright (C) 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# https://github.com/dw-0/simple-config-parser #
# #
@@ -8,47 +8,24 @@
from __future__ import annotations
import re
import secrets
import string
from pathlib import Path
from typing import Callable, Dict, List, Match, Tuple, TypedDict
from typing import Callable, Dict, List
from ..simple_config_parser.constants import (
BOOLEAN_STATES,
EMPTY_LINE_RE,
HEADER_IDENT,
LINE_COMMENT_RE,
OPTION_RE,
OPTIONS_BLOCK_START_RE,
SECTION_RE,
)
_UNSET = object()
class Section(TypedDict):
"""
A single section in the config file
- _raw: The raw representation of the section name
- options: A list of options in the section
"""
_raw: str
options: List[Option]
class Option(TypedDict, total=False):
"""
A single option in a section in the config file
- is_multiline: Whether the option is a multiline option
- option: The name of the option
- value: The value of the option
- _raw: The raw representation of the option
- _raw_value: The raw value of the option
A multinline option is an option that contains multiple lines of text following
the option name in the next line. The value of a multiline option is a list of
strings, where each string represents a single line of text.
"""
is_multiline: bool
option: str
value: str | List[str]
_raw: str
_raw_value: str | List[str]
class NoSectionError(Exception):
"""Raised when a section is not defined"""
@@ -57,14 +34,6 @@ class NoSectionError(Exception):
super().__init__(msg)
class NoOptionError(Exception):
"""Raised when an option is not defined in a section"""
def __init__(self, option: str, section: str):
msg = f"Option '{option}' in section '{section}' is not defined"
super().__init__(msg)
class DuplicateSectionError(Exception):
"""Raised when a section is defined more than once"""
@@ -73,11 +42,11 @@ class DuplicateSectionError(Exception):
super().__init__(msg)
class DuplicateOptionError(Exception):
"""Raised when an option is defined more than once"""
class NoOptionError(Exception):
"""Raised when an option is not defined in a section"""
def __init__(self, option: str, section: str):
msg = f"Option '{option}' in section '{section}' is defined more than once"
msg = f"Option '{option}' in section '{section}' is not defined"
super().__init__(msg)
@@ -85,123 +54,208 @@ class DuplicateOptionError(Exception):
class SimpleConfigParser:
"""A customized config parser targeted at handling Klipper style config files"""
_SECTION_RE = re.compile(r"\s*\[(\w+\s?.+)]\s*([#;].*)?$")
_OPTION_RE = re.compile(r"^\s*(\w+)\s*[:=]\s*([^=:].*)\s*([#;].*)?$")
_MLOPTION_RE = re.compile(r"^\s*(\w+)\s*[:=]\s*([#;].*)?$")
_COMMENT_RE = re.compile(r"^\s*([#;].*)?$")
_EMPTY_LINE_RE = re.compile(r"^\s*$")
def __init__(self) -> None:
self.header: List[str] = []
self.config: Dict = {}
self.current_section: str | None = None
self.current_opt_block: str | None = None
self.current_collector: str | None = None
self.in_option_block: bool = False
BOOLEAN_STATES = {
"1": True,
"yes": True,
"true": True,
"on": True,
"0": False,
"no": False,
"false": False,
"off": False,
}
def _match_section(self, line: str) -> bool:
"""Wheter or not the given line matches the definition of a section"""
return SECTION_RE.match(line) is not None
def __init__(self):
self._config: Dict = {}
self._header: List[str] = []
self._all_sections: List[str] = []
self._all_options: Dict = {}
self.section_name: str = ""
self.in_option_block: bool = False # whether we are in a multiline option block
def _match_option(self, line: str) -> bool:
"""Wheter or not the given line matches the definition of an option"""
return OPTION_RE.match(line) is not None
def read(self, file: Path) -> None:
"""
Read the given file and store the result in the internal state.
Call this method before using any other methods. Calling this method
multiple times will reset the internal state on each call.
"""
def _match_options_block_start(self, line: str) -> bool:
"""Wheter or not the given line matches the definition of a multiline option"""
return OPTIONS_BLOCK_START_RE.match(line) is not None
self._reset_state()
def _match_line_comment(self, line: str) -> bool:
"""Wheter or not the given line matches the definition of a comment"""
return LINE_COMMENT_RE.match(line) is not None
try:
with open(file, "r") as f:
self._parse_config(f.readlines())
def _match_empty_line(self, line: str) -> bool:
"""Wheter or not the given line matches the definition of an empty line"""
return EMPTY_LINE_RE.match(line) is not None
except OSError:
raise
def _parse_line(self, line: str) -> None:
"""Parses a line and determines its type"""
if self._match_section(line):
self.current_collector = None
self.current_opt_block = None
self.current_section = SECTION_RE.match(line).group(1)
self.config[self.current_section] = {"_raw": line}
def _reset_state(self):
"""Reset the internal state."""
elif self._match_option(line):
self.current_collector = None
self.current_opt_block = None
option = OPTION_RE.match(line).group(1)
value = OPTION_RE.match(line).group(2)
self.config[self.current_section][option] = {"_raw": line, "value": value}
self._config.clear()
self._header.clear()
self._all_sections.clear()
self._all_options.clear()
self.section_name = ""
self.in_option_block = False
elif self._match_options_block_start(line):
self.current_collector = None
option = OPTIONS_BLOCK_START_RE.match(line).group(1)
self.current_opt_block = option
self.config[self.current_section][option] = {"_raw": line, "value": []}
def write(self, filename):
"""Write the internal state to the given file"""
elif self.current_opt_block is not None:
self.config[self.current_section][self.current_opt_block]["value"].append(
line
)
content = self._construct_content()
elif self._match_empty_line(line) or self._match_line_comment(line):
self.current_opt_block = None
with open(filename, "w") as f:
f.write(content)
# if current_section is None, we are at the beginning of the file,
# so we consider the part up to the first section as the file header
if not self.current_section:
self.config.setdefault(HEADER_IDENT, []).append(line)
else:
section = self.config[self.current_section]
def _construct_content(self) -> str:
"""
Constructs the content of the configuration file based on the internal state of
the _config object by iterating over the sections and their options. It starts
by checking if a header is present and extends the content list with its elements.
Then, for each section, it appends the raw representation of the section to the
content list. If the section has a body, it iterates over its options and extends
the content list with their raw representations. If an option is multiline, it
also extends the content list with its raw value. Finally, the content list is
joined into a single string and returned.
# set the current collector to a new value, so that continuous
# empty lines or comments are collected into the same collector
if not self.current_collector:
self.current_collector = self._generate_rand_id()
section[self.current_collector] = []
:return: The content of the configuration file as a string
"""
content: List[str] = []
if self._header is not None:
content.extend(self._header)
for section in self._config:
content.append(self._config[section]["_raw"])
section[self.current_collector].append(line)
if (sec_body := self._config[section].get("body")) is not None:
for option in sec_body:
content.extend(option["_raw"])
if option["is_multiline"]:
content.extend(option["_raw_value"])
content: str = "".join(content)
def read_file(self, file: Path) -> None:
"""Read and parse a config file"""
with open(file, "r") as file:
for line in file:
self._parse_line(line)
return content
# print(json.dumps(self.config, indent=4))
def sections(self) -> List[str]:
"""Return a list of section names"""
def write_file(self, file: Path) -> None:
"""Write the current config to the config file"""
if not file:
raise ValueError("No config file specified")
return self._all_sections
with open(file, "w") as file:
self._write_header(file)
self._write_sections(file)
def _write_header(self, file) -> None:
"""Write the header to the config file"""
for line in self.config.get(HEADER_IDENT, []):
file.write(line)
def _write_sections(self, file) -> None:
"""Write the sections to the config file"""
for section in self.get_sections():
for key, value in self.config[section].items():
self._write_section_content(file, key, value)
def _write_section_content(self, file, key, value) -> None:
"""Write the content of a section to the config file"""
if key == "_raw":
file.write(value)
elif key.startswith("#_"):
for line in value:
file.write(line)
elif isinstance(value["value"], list):
file.write(value["_raw"])
for line in value["value"]:
file.write(line)
else:
file.write(value["_raw"])
def get_sections(self) -> List[str]:
"""Return a list of all section names, but exclude any section starting with '#_'"""
return list(
filter(
lambda section: not section.startswith("#_"),
self.config.keys(),
)
)
def has_section(self, section: str) -> bool:
"""Check if a section exists"""
return section in self.get_sections()
def add_section(self, section: str) -> None:
"""Add a new section to the internal state"""
if section in self._all_sections:
"""Add a new section to the config"""
if section in self.get_sections():
raise DuplicateSectionError(section)
self._all_sections.append(section)
self._all_options[section] = {}
self._config[section] = {"_raw": f"\n[{section}]\n", "body": []}
if len(self.get_sections()) >= 1:
self._check_set_section_spacing()
self.config[section] = {"_raw": f"[{section}]\n"}
def _check_set_section_spacing(self):
prev_section_name: str = self.get_sections()[-1]
prev_section_content: Dict = self.config[prev_section_name]
last_option_name: str = list(prev_section_content.keys())[-1]
if last_option_name.startswith("#_"):
last_elem_value: str = prev_section_content[last_option_name][-1]
# if the last section is a collector, we first check if the last element
# in the collector ends with a newline. if it does not, we append a newline.
# this can happen if the config file does not end with a newline.
if not last_elem_value.endswith("\n"):
prev_section_content[last_option_name][-1] = f"{last_elem_value}\n"
# if the last item in a collector is not a newline, we append a newline, so
# that the new section is seperated from the options of the previous section
# by a newline
if last_elem_value != "\n":
prev_section_content[last_option_name].append("\n")
else:
prev_section_content[self._generate_rand_id()] = ["\n"]
def remove_section(self, section: str) -> None:
"""Remove the given section"""
"""Remove a section from the config"""
self.config.pop(section, None)
if section not in self._all_sections:
raise NoSectionError(section)
def get_options(self, section: str) -> List[str]:
"""Return a list of all option names for a given section"""
return list(
filter(
lambda option: option != "_raw" and not option.startswith("#_"),
self.config[section].keys(),
)
)
self._all_sections.pop(self._all_sections.index(section))
self._all_options.pop(section)
self._config.pop(section)
def has_option(self, section: str, option: str) -> bool:
"""Check if an option exists in a section"""
return self.has_section(section) and option in self.get_options(section)
def options(self, section) -> List[str]:
"""Return a list of option names for the given section name"""
def set_option(self, section: str, option: str, value: str | List[str]) -> None:
"""
Set the value of an option in a section. If the section does not exist,
it is created. If the option does not exist, it is created.
"""
if not self.has_section(section):
self.add_section(section)
return self._all_options.get(section)
if not self.has_option(section, option):
self.config[section][option] = {
"_raw": f"{option}:\n"
if isinstance(value, list)
else f"{option}: {value}\n",
"value": value,
}
else:
opt = self.config[section][option]
if not isinstance(value, list):
opt["_raw"] = opt["_raw"].replace(opt["value"], value)
opt["value"] = value
def get(
def remove_option(self, section: str, option: str) -> None:
"""Remove an option from a section"""
self.config[section].pop(option, None)
def getval(
self, section: str, option: str, fallback: str | _UNSET = _UNSET
) -> str | List[str]:
"""
@@ -210,15 +264,12 @@ class SimpleConfigParser:
If the key is not found and 'fallback' is provided, it is used as
a fallback value.
"""
try:
if section not in self._all_sections:
if section not in self.get_sections():
raise NoSectionError(section)
if option not in self._all_options.get(section):
if option not in self.get_options(section):
raise NoOptionError(option, section)
return self._all_options[section][option]
return self.config[section][option]["value"]
except (NoSectionError, NoOptionError):
if fallback is _UNSET:
raise
@@ -226,25 +277,29 @@ class SimpleConfigParser:
def getint(self, section: str, option: str, fallback: int | _UNSET = _UNSET) -> int:
"""Return the value of the given option in the given section as an int"""
return self._get_conv(section, option, int, fallback=fallback)
def getfloat(
self, section: str, option: str, fallback: float | _UNSET = _UNSET
) -> float:
"""Return the value of the given option in the given section as a float"""
return self._get_conv(section, option, float, fallback=fallback)
def getboolean(
self, section: str, option: str, fallback: bool | _UNSET = _UNSET
) -> bool:
"""Return the value of the given option in the given section as a boolean"""
return self._get_conv(
section, option, self._convert_to_boolean, fallback=fallback
)
def _convert_to_boolean(self, value) -> bool:
if value.lower() not in self.BOOLEAN_STATES:
def _convert_to_boolean(self, value: str) -> bool:
"""Convert a string to a boolean"""
if isinstance(value, bool):
return value
if value.lower() not in BOOLEAN_STATES:
raise ValueError("Not a boolean: %s" % value)
return self.BOOLEAN_STATES[value.lower()]
return BOOLEAN_STATES[value.lower()]
def _get_conv(
self,
@@ -253,300 +308,18 @@ class SimpleConfigParser:
conv: Callable[[str], int | float | bool],
fallback: _UNSET = _UNSET,
) -> int | float | bool:
"""Return the value of the given option in the given section as a converted value"""
try:
return conv(self.get(section, option, fallback))
except:
return conv(self.getval(section, option, fallback))
except (ValueError, TypeError, AttributeError) as e:
if fallback is not _UNSET:
return fallback
raise
def items(self, section: str) -> List[Tuple[str, str]]:
"""Return a list of (option, value) tuples for a specific section"""
if section not in self._all_sections:
raise NoSectionError(section)
result = []
for _option in self._all_options[section]:
result.append((_option, self._all_options[section][_option]))
return result
def set(
self,
section: str,
option: str,
value: str,
multiline: bool = False,
indent: int = 4,
) -> None:
"""Set the given option to the given value in the given section
If the option is already defined, it will be overwritten. If the option
is not defined yet, it will be added to the section body.
The multiline parameter can be used to specify whether the value is
multiline or not. If it is not specified, the value will be considered
as multiline if it contains a newline character. The value will then be split
into multiple lines. If the value does not contain a newline character, it
will be considered as a single line value. The indent parameter can be used
to specify the indentation of the multiline value. Indentations are with spaces.
:param section: The section to set the option in
:param option: The option to set
:param value: The value to set
:param multiline: Whether the value is multiline or not
:param indent: The indentation for multiline values
"""
if section not in self._all_sections:
raise NoSectionError(section)
# prepare the options value and raw value depending on the multiline flag
_raw_value: List[str] | None = None
if multiline or "\n" in value:
_multiline = True
_raw: str = f"{option}:\n"
_value: List[str] = value.split("\n")
_raw_value: List[str] = [f"{' ' * indent}{v}\n" for v in _value]
else:
_multiline = False
_raw: str = f"{option}: {value}\n"
_value: str = value
# the option does not exist yet
if option not in self._all_options.get(section):
_option: Option = {
"is_multiline": _multiline,
"option": option,
"value": _value,
"_raw": _raw,
}
if _raw_value is not None:
_option["_raw_value"] = _raw_value
self._config[section]["body"].insert(0, _option)
# the option exists and we need to update it
else:
for _option in self._config[section]["body"]:
if _option["option"] == option:
if multiline:
_option["_raw"] = _raw
else:
# we preserve inline comments by replacing the old value with the new one
_option["_raw"] = _option["_raw"].replace(
_option["value"], _value
)
_option["value"] = _value
if _raw_value is not None:
_option["_raw_value"] = _raw_value
break
self._all_options[section][option] = _value
def remove_option(self, section: str, option: str) -> None:
"""Remove the given option from the given section"""
if section not in self._all_sections:
raise NoSectionError(section)
if option not in self._all_options.get(section):
raise NoOptionError(option, section)
for _option in self._config[section]["body"]:
if _option["option"] == option:
del self._all_options[section][option]
self._config[section]["body"].remove(_option)
break
def has_section(self, section: str) -> bool:
"""Return True if the given section exists, False otherwise"""
return section in self._all_sections
def has_option(self, section: str, option: str) -> bool:
"""Return True if the given option exists in the given section, False otherwise"""
return option in self._all_options.get(section)
def _is_section(self, line: str) -> bool:
"""Check if the given line contains a section definition"""
return self._SECTION_RE.match(line) is not None
def _is_option(self, line: str) -> bool:
"""Check if the given line contains an option definition"""
match: Match[str] | None = self._OPTION_RE.match(line)
if not match:
return False
# if there is no value, it's not a regular option but a multiline option
if match.group(2).strip() == "":
return False
if not match.group(1).strip() == "":
return True
return False
def _is_comment(self, line: str) -> bool:
"""Check if the given line is a comment"""
return self._COMMENT_RE.match(line) is not None
def _is_empty_line(self, line: str) -> bool:
"""Check if the given line is an empty line"""
return self._EMPTY_LINE_RE.match(line) is not None
def _is_multiline_option(self, line: str) -> bool:
"""Check if the given line starts a multiline option block"""
match: Match[str] | None = self._MLOPTION_RE.match(line)
if not match:
return False
return True
def _parse_config(self, content: List[str]) -> None:
"""Parse the given content and store the result in the internal state"""
_curr_multi_opt = ""
# THE ORDER MATTERS, DO NOT REORDER THE CONDITIONS!
for line in content:
if self._is_section(line):
self._parse_section(line)
elif self._is_option(line):
self._parse_option(line)
# if it's not a regular option with the value inline,
# it might be a might be a multiline option block
elif self._is_multiline_option(line):
self.in_option_block = True
_curr_multi_opt = self._OPTION_RE.match(line).group(1).strip()
self._add_option_to_section_body(_curr_multi_opt, "", line)
elif self.in_option_block:
self._parse_multiline_option(_curr_multi_opt, line)
# if it's nothing from above, it's probably a comment or an empty line
elif self._is_comment(line) or self._is_empty_line(line):
self._parse_comment(line)
def _parse_section(self, line: str) -> None:
"""Parse a section line and store the result in the internal state"""
match: Match[str] | None = self._SECTION_RE.match(line)
if not match:
return
self.in_option_block = False
section_name: str = match.group(1).strip()
self._store_internal_state_section(section_name, line)
def _store_internal_state_section(self, section: str, raw_value: str) -> None:
"""Store the given section and its raw value in the internal state"""
if section in self._all_sections:
raise DuplicateSectionError(section)
self.section_name = section
self._all_sections.append(section)
self._all_options[section] = {}
self._config[section]: Section = {"_raw": raw_value, "body": []}
def _parse_option(self, line: str) -> None:
"""Parse an option line and store the result in the internal state"""
self.in_option_block = False
match: Match[str] | None = self._OPTION_RE.match(line)
if not match:
return
option: str = match.group(1).strip()
value: str = match.group(2).strip()
if ";" in value:
i = value.index(";")
value = value[:i].strip()
elif "#" in value:
i = value.index("#")
value = value[:i].strip()
self._store_internal_state_option(option, value, line)
def _store_internal_state_option(
self, option: str, value: str, raw_value: str
) -> None:
"""Store the given option and its raw value in the internal state"""
section_options = self._all_options.setdefault(self.section_name, {})
if option in section_options:
raise DuplicateOptionError(option, self.section_name)
section_options[option] = value
self._add_option_to_section_body(option, value, raw_value)
def _parse_multiline_option(self, curr_ml_opt: str, line: str) -> None:
"""Parse a multiline option line and store the result in the internal state"""
section_options = self._all_options.setdefault(self.section_name, {})
multiline_options = section_options.setdefault(curr_ml_opt, [])
_cleaned_line = line.strip().strip("\n")
if _cleaned_line and not self._is_comment(line):
multiline_options.append(_cleaned_line)
# add the option to the internal multiline option value state
self._ensure_section_body_exists()
for _option in self._config[self.section_name]["body"]:
if _option.get("option") == curr_ml_opt:
_option.update(
is_multiline=True,
_raw_value=_option.get("_raw_value", []) + [line],
value=multiline_options,
)
def _parse_comment(self, line: str) -> None:
"""
Parse a comment line and store the result in the internal state
If the there was no previous section parsed, the lines are handled as
the file header and added to the internal header list as it means, that
we are at the very top of the file.
"""
self.in_option_block = False
if not self.section_name:
self._header.append(line)
else:
self._add_option_to_section_body("", "", line)
def _ensure_section_body_exists(self) -> None:
"""
Ensure that the section body exists in the internal state.
If the section body does not exist, it is created as an empty list
"""
if self.section_name not in self._config:
self._config.setdefault(self.section_name, {}).setdefault("body", [])
def _add_option_to_section_body(
self, option: str, value: str, line: str, is_multiline: bool = False
) -> None:
"""Add a raw option line to the internal state"""
self._ensure_section_body_exists()
new_option: Option = {
"is_multiline": is_multiline,
"option": option,
"value": value,
"_raw": line,
}
option_body = self._config[self.section_name]["body"]
option_body.append(new_option)
raise ValueError(
f"Cannot convert {self.getval(section, option)} to {conv.__name__}"
) from e
def _generate_rand_id(self) -> str:
"""Generate a random id with 6 characters"""
chars = string.ascii_letters + string.digits
rand_string = "".join(secrets.choice(chars) for _ in range(12))
return f"#_{rand_string}"

View File

@@ -1,21 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Test SimpleConfigParser" type="tests" factoryName="py.test">
<module name="simple-config-parser" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.8 (simple-config-parser)" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;-s -vv&quot;" />
<option name="_new_target" value="&quot;&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
# a comment at the very top
# should be treated as the file header
# up to the first section, including all blank lines
[section_1]
option_1: value_1
option_1_1: True # this is a boolean
option_1_2: 5 ; this is an integer
option_1_3: 1.123 #;this is a float
[section_2] ; comment
option_2: value_2
; comment
[section_3]
option_3: value_3 # comment
[section_4]
# comment
option_4: value_4
[section number 5]
#option_5: value_5
option_5 = this.is.value-5
multi_option:
# these are multi-line values
value_5_1
value_5_2 ; here is a comment
value_5_3
option_5_1: value_5_1

View File

@@ -0,0 +1,33 @@
# a comment at the very top
# should be treated as the file header
# up to the first section, including all blank lines
[section_1]
option_1: value_1
option_1_1: True # this is a boolean
option_1_2: 5 ; this is an integer
option_1_3: 1.123 #;this is a float
[section_2] ; comment
option_2: value_2
; comment
[section_3]
option_3: value_3 # comment
[section_4]
# comment
option_4: value_4
[section number 5]
#option_5: value_5
option_5 = this.is.value-5
multi_option:
# these are multi-line values
value_5_1
value_5_2 ; here is a comment
value_5_3
option_5_1: value_5_1
# config ending with a comment

View File

@@ -0,0 +1,94 @@
# a comment at the very top
# should be treated as the file header
# up to the first section, including all blank lines
[section_1]
option_1: value_1
option_1_1: True # this is a boolean
option_1_2: 5 ; this is an integer
option_1_3: 1.123 #;this is a float
[section_2] ; comment
option_2: value_2
; comment
[section_3]
option_3: value_3 # comment
[section_4]
# comment
option_4: value_4
[section number 5]
#option_5: value_5
option_5 = this.is.value-5
multi_option:
# these are multi-line values
value_5_1
value_5_2 ; here is a comment
value_5_3
option_5_1: value_5_1
[gcode_macro M117]
rename_existing: M117.1
gcode:
{% if rawparams %}
{% set escaped_msg = rawparams.split(';', 1)[0].split('\x23', 1)[0]|replace('"', '\\"') %}
SET_DISPLAY_TEXT MSG="{escaped_msg}"
RESPOND TYPE=command MSG="{escaped_msg}"
{% else %}
SET_DISPLAY_TEXT
{% endif %}
# SDCard 'looping' (aka Marlin M808 commands) support
#
# Support SDCard looping
[sdcard_loop]
[gcode_macro M486]
gcode:
# Parameters known to M486 are as follows:
# [C<flag>] Cancel the current object
# [P<index>] Cancel the object with the given index
# [S<index>] Set the index of the current object.
# If the object with the given index has been canceled, this will cause
# the firmware to skip to the next object. The value -1 is used to
# indicate something that isnt an object and shouldnt be skipped.
# [T<count>] Reset the state and set the number of objects
# [U<index>] Un-cancel the object with the given index. This command will be
# ignored if the object has already been skipped
{% if 'exclude_object' not in printer %}
{action_raise_error("[exclude_object] is not enabled")}
{% endif %}
{% if 'T' in params %}
EXCLUDE_OBJECT RESET=1
{% for i in range(params.T | int) %}
EXCLUDE_OBJECT_DEFINE NAME={i}
{% endfor %}
{% endif %}
{% if 'C' in params %}
EXCLUDE_OBJECT CURRENT=1
{% endif %}
{% if 'P' in params %}
EXCLUDE_OBJECT NAME={params.P}
{% endif %}
{% if 'S' in params %}
{% if params.S == '-1' %}
{% if printer.exclude_object.current_object %}
EXCLUDE_OBJECT_END NAME={printer.exclude_object.current_object}
{% endif %}
{% else %}
EXCLUDE_OBJECT_START NAME={params.S}
{% endif %}
{% endif %}
{% if 'U' in params %}
EXCLUDE_OBJECT RESET=1 NAME={params.U}
{% endif %}

View File

@@ -1,95 +0,0 @@
import pytest
from src.simple_config_parser.simple_config_parser import SimpleConfigParser
@pytest.fixture
def parser():
parser = SimpleConfigParser()
parser._header = ["header1\n", "header2\n"]
parser._config = {
"section1": {
"_raw": "[section1]\n",
"body": [
{
"_raw": "option1: value1\n",
"_raw_value": "value1\n",
"is_multiline": False,
"option": "option1",
"value": "value1",
},
{
"_raw": "option2: value2\n",
"_raw_value": "value2\n",
"is_multiline": False,
"option": "option2",
"value": "value2",
},
],
},
"section2": {
"_raw": "[section2]\n",
"body": [
{
"_raw": "option3: value3\n",
"_raw_value": "value3\n",
"is_multiline": False,
"option": "option3",
"value": "value3",
},
],
},
"section3": {
"_raw": "[section3]\n",
"body": [
{
"_raw": "option4:\n",
"_raw_value": [" value4\n", " value5\n", " value6\n"],
"is_multiline": True,
"option": "option4",
"value": ["value4", "value5", "value6"],
},
],
},
}
return parser
def test_construct_content(parser):
content = parser._construct_content()
assert (
content == "header1\nheader2\n"
"[section1]\n"
"option1: value1\n"
"option2: value2\n"
"[section2]\n"
"option3: value3\n"
"[section3]\n"
"option4:\n"
" value4\n"
" value5\n"
" value6\n"
)
def test_construct_content_no_header(parser):
parser._header = None
content = parser._construct_content()
assert (
content == "[section1]\n"
"option1: value1\n"
"option2: value2\n"
"[section2]\n"
"option3: value3\n"
"[section3]\n"
"option4:\n"
" value4\n"
" value5\n"
" value6\n"
)
def test_construct_content_no_sections(parser):
parser._config = {}
content = parser._construct_content()
assert content == "".join(parser._header)

View File

@@ -1,84 +0,0 @@
import pytest
from src.simple_config_parser.simple_config_parser import (
DuplicateOptionError,
DuplicateSectionError,
SimpleConfigParser,
)
@pytest.fixture
def parser():
return SimpleConfigParser()
class TestInternalStateChanges:
@pytest.mark.parametrize(
"given", ["dummy_section", "dummy_section 2", "another_section"]
)
def test_ensure_section_body_exists(self, parser, given):
parser._config = {}
parser.section_name = given
parser._ensure_section_body_exists()
assert parser._config[given] is not None
assert parser._config[given]["body"] == []
def test_add_option_to_section_body(self):
pass
@pytest.mark.parametrize(
"given", ["dummy_section", "dummy_section 2", "another_section\n"]
)
def test_store_internal_state_section(self, parser, given):
parser._store_internal_state_section(given, given)
assert parser._all_sections == [given]
assert parser._all_options[given] == {}
assert parser._config[given]["body"] == []
assert parser._config[given]["_raw"] == given
def test_duplicate_section_error(self, parser):
section_name = "dummy_section"
parser._all_sections = [section_name]
with pytest.raises(DuplicateSectionError) as excinfo:
parser._store_internal_state_section(section_name, section_name)
message = f"Section '{section_name}' is defined more than once"
assert message in str(excinfo.value)
# Check that the internal state of the parser is correct
assert parser.in_option_block is False
assert parser.section_name == ""
assert parser._all_sections == [section_name]
@pytest.mark.parametrize(
"given_name, given_value, given_raw_value",
[("dummyoption", "dummyvalue", "dummyvalue\n")],
)
def test_store_internal_state_option(
self, parser, given_name, given_value, given_raw_value
):
parser.section_name = "dummy_section"
parser._store_internal_state_option(given_name, given_value, given_raw_value)
assert parser._all_options[parser.section_name] == {given_name: given_value}
new_option = {
"is_multiline": False,
"option": given_name,
"value": given_value,
"_raw": given_raw_value,
}
assert parser._config[parser.section_name]["body"] == [new_option]
def test_duplicate_option_error(self, parser):
option_name = "dummyoption"
value = "dummyvalue"
parser.section_name = "dummy_section"
parser._all_options = {parser.section_name: {option_name: value}}
with pytest.raises(DuplicateOptionError) as excinfo:
parser._store_internal_state_option(option_name, value, value)
message = f"Option '{option_name}' in section '{parser.section_name}' is defined more than once"
assert message in str(excinfo.value)

View File

@@ -1,6 +0,0 @@
testcases = [
"# comment # 1",
"; comment # 2",
" ; indented comment",
" # another indented comment",
]

View File

@@ -1,24 +0,0 @@
testcases = [
("option: value", "option", "value"),
("option : value", "option", "value"),
("option :value", "option", "value"),
("option= value", "option", "value"),
("option = value", "option", "value"),
("option =value", "option", "value"),
("option: value\n", "option", "value"),
("option: value # inline comment", "option", "value"),
("option: value # inline comment\n", "option", "value"),
(
"description: Helper: park toolhead used in PAUSE and CANCEL_PRINT",
"description",
"Helper: park toolhead used in PAUSE and CANCEL_PRINT",
),
("description: homing!", "description", "homing!"),
("description: inline macro :-)", "description", "inline macro :-)"),
("path: %GCODES_DIR%", "path", "%GCODES_DIR%"),
(
"serial = /dev/serial/by-id/<your-mcu-id>",
"serial",
"/dev/serial/by-id/<your-mcu-id>",
),
]

View File

@@ -1,8 +0,0 @@
testcases = [
("[test_section]", "test_section"),
("[test_section two]", "test_section two"),
("[section1] # inline comment", "section1"),
("[section2] ; second comment", "section2"),
("[include moonraker-obico-update.cfg]", "include moonraker-obico-update.cfg"),
("[include moonraker_obico_macros.cfg]", "include moonraker_obico_macros.cfg"),
]

View File

@@ -1,92 +0,0 @@
import pytest
from data.case_parse_comment import testcases as case_parse_comment
from data.case_parse_option import testcases as case_parse_option
from data.case_parse_section import testcases as case_parse_section
from src.simple_config_parser.simple_config_parser import (
Option,
SimpleConfigParser,
)
@pytest.fixture
def parser():
return SimpleConfigParser()
class TestLineParsing:
@pytest.mark.parametrize("given, expected", [*case_parse_section])
def test_parse_section(self, parser, given, expected):
parser._parse_section(given)
# Check that the internal state of the parser is correct
assert parser.section_name == expected
assert parser.in_option_block is False
assert parser._all_sections == [expected]
assert parser._config[expected]["_raw"] == given
assert parser._config[expected]["body"] == []
@pytest.mark.parametrize(
"given, expected_option, expected_value", [*case_parse_option]
)
def test_parse_option(self, parser, given, expected_option, expected_value):
section_name = "test_section"
parser.section_name = section_name
parser._parse_option(given)
# Check that the internal state of the parser is correct
assert parser.section_name == section_name
assert parser.in_option_block is False
assert parser._all_options[section_name][expected_option] == expected_value
section_option = parser._config[section_name]["body"][0]
assert section_option["option"] == expected_option
assert section_option["value"] == expected_value
assert section_option["_raw"] == given
@pytest.mark.parametrize(
"option, next_line",
[("gcode", "next line"), ("gcode", " {{% some jinja template %}}")],
)
def test_parse_multiline_option(self, parser, option, next_line):
parser.section_name = "dummy_section"
parser.in_option_block = True
parser._add_option_to_section_body(option, "", option)
parser._parse_multiline_option(option, next_line)
cleaned_next_line = next_line.strip().strip("\n")
assert parser._all_options[parser.section_name] is not None
assert parser._all_options[parser.section_name][option] == [cleaned_next_line]
expected_option: Option = {
"is_multiline": True,
"option": option,
"value": [cleaned_next_line],
"_raw": option,
"_raw_value": [next_line],
}
assert parser._config[parser.section_name]["body"] == [expected_option]
@pytest.mark.parametrize("given", [*case_parse_comment])
def test_parse_comment(self, parser, given):
parser.section_name = "dummy_section"
parser._parse_comment(given)
# internal state checks after parsing
assert parser.in_option_block is False
expected_option = {
"is_multiline": False,
"_raw": given,
"option": "",
"value": "",
}
assert parser._config[parser.section_name]["body"] == [expected_option]
@pytest.mark.parametrize("given", ["# header line", "; another header line"])
def test_parse_header_comment(self, parser, given):
parser.section_name = ""
parser._parse_comment(given)
assert parser.in_option_block is False
assert parser._header == [given]

View File

@@ -1,9 +0,0 @@
testcases = [
("# an arbitrary comment", True),
("; another arbitrary comment", True),
(" ; indented comment", True),
(" # indented comment", True),
("not_a: comment", False),
("also_not_a= comment", False),
("[definitely_not_a_comment]", False),
]

View File

@@ -1,9 +0,0 @@
testcases = [
("", True),
(" ", True),
("not empty", False),
(" # indented comment", False),
("not: empty", False),
("also_not= empty", False),
("[definitely_not_empty]", False),
]

View File

@@ -1,17 +0,0 @@
testcases = [
("valid_option:", True),
("valid_option:\n", True),
("valid_option: ; inline comment", True),
("valid_option: # inline comment", True),
("valid_option :", True),
("valid_option=", True),
("valid_option= ", True),
("valid_option =", True),
("valid_option = ", True),
("invalid_option ==", False),
("invalid_option :=", False),
("not_a_valid_option", False),
("", False),
("# that's a comment", False),
("; that's a comment", False),
]

View File

@@ -1,30 +0,0 @@
testcases = [
("valid_option: value", True),
("valid_option: value\n", True),
("valid_option: value ; inline comment", True),
("valid_option: value # inline comment", True),
("valid_option: value # inline comment\n", True),
("valid_option : value", True),
("valid_option :value", True),
("valid_option= value", True),
("valid_option = value", True),
("valid_option =value", True),
("invalid_option:", False),
("invalid_option=", False),
("invalid_option:: value", False),
("invalid_option :: value", False),
("invalid_option ::value", False),
("invalid_option== value", False),
("invalid_option == value", False),
("invalid_option ==value", False),
("invalid_option:= value", False),
("invalid_option := value", False),
("invalid_option :=value", False),
("[that_is_a_section]", False),
("[that_is_section two]", False),
("not_a_valid_option", False),
("description: homing!", True),
("description: inline macro :-)", True),
("path: %GCODES_DIR%", True),
("serial = /dev/serial/by-id/<your-mcu-id>", True),
]

View File

@@ -1,12 +0,0 @@
testcases = [
("[example_section]", True),
("[gcode_macro CANCEL_PRINT]", True),
("[gcode_macro SET_PAUSE_NEXT_LAYER]", True),
("[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]", True),
("[update_manager moonraker-obico]", True),
("[include moonraker_obico_macros.cfg]", True),
("[include moonraker-obico-update.cfg]", True),
("[example_section two]", True),
("not_a_valid_section", False),
("section: invalid", False),
]

View File

@@ -1,37 +0,0 @@
import pytest
from data.case_line_is_comment import testcases as case_line_is_comment
from data.case_line_is_empty import testcases as case_line_is_empty
from data.case_line_is_multiline_option import (
testcases as case_line_is_multiline_option,
)
from data.case_line_is_option import testcases as case_line_is_option
from data.case_line_is_section import testcases as case_line_is_section
from src.simple_config_parser.simple_config_parser import SimpleConfigParser
@pytest.fixture
def parser():
return SimpleConfigParser()
class TestLineTypeDetection:
@pytest.mark.parametrize("given, expected", [*case_line_is_section])
def test_line_is_section(self, parser, given, expected):
assert parser._is_section(given) is expected
@pytest.mark.parametrize("given, expected", [*case_line_is_option])
def test_line_is_option(self, parser, given, expected):
assert parser._is_option(given) is expected
@pytest.mark.parametrize("given, expected", [*case_line_is_multiline_option])
def test_line_is_multiline_option(self, parser, given, expected):
assert parser._is_multiline_option(given) is expected
@pytest.mark.parametrize("given, expected", [*case_line_is_comment])
def test_line_is_comment(self, parser, given, expected):
assert parser._is_comment(given) is expected
@pytest.mark.parametrize("given, expected", [*case_line_is_empty])
def test_line_is_empty(self, parser, given, expected):
assert parser._is_empty_line(given) is expected

View File

@@ -1,196 +0,0 @@
import pytest
from src.simple_config_parser.simple_config_parser import (
DuplicateSectionError,
NoOptionError,
NoSectionError,
SimpleConfigParser,
)
@pytest.fixture
def parser():
return SimpleConfigParser()
class TestPublicAPI:
def test_has_section(self, parser):
parser._all_sections = ["section1"]
assert parser.has_section("section1") is True
@pytest.mark.parametrize("section", ["section1", "section2", "section three"])
def test_add_section(self, parser, section):
parser.add_section(section)
assert section in parser._all_sections
assert parser._all_options[section] == {}
cfg_section = {"_raw": f"\n[{section}]\n", "body": []}
assert parser._config[section] == cfg_section
@pytest.mark.parametrize("section", ["section1", "section2", "section three"])
def test_add_existing_section(self, parser, section):
parser._all_sections = [section]
with pytest.raises(DuplicateSectionError):
parser.add_section(section)
assert parser._all_sections == [section]
@pytest.mark.parametrize("section", ["section1", "section2", "section three"])
def test_remove_section(self, parser, section):
parser.add_section(section)
parser.remove_section(section)
assert section not in parser._all_sections
assert section not in parser._all_options
assert section not in parser._config
@pytest.mark.parametrize("section", ["section1", "section2", "section three"])
def test_remove_non_existing_section(self, parser, section):
with pytest.raises(NoSectionError):
parser.remove_section(section)
def test_get_all_sections(self, parser):
parser.add_section("section1")
parser.add_section("section2")
parser.add_section("section three")
assert parser.sections() == ["section1", "section2", "section three"]
def test_has_option(self, parser):
parser.add_section("section1")
parser.set("section1", "option1", "value1")
assert parser.has_option("section1", "option1") is True
@pytest.mark.parametrize(
"section, option, value",
[
("section1", "option1", "value1"),
("section2", "option2", "value2"),
("section three", "option3", "value three"),
],
)
def test_set_new_option(self, parser, section, option, value):
parser.add_section(section)
parser.set(section, option, value)
assert section in parser._all_sections
assert option in parser._all_options[section]
assert parser._all_options[section][option] == value
assert parser._config[section]["body"][0]["is_multiline"] is False
assert parser._config[section]["body"][0]["option"] == option
assert parser._config[section]["body"][0]["value"] == value
assert parser._config[section]["body"][0]["_raw"] == f"{option}: {value}\n"
def test_set_existing_option(self, parser):
section, option, value1, value2 = "section1", "option1", "value1", "value2"
parser.add_section(section)
parser.set(section, option, value1)
parser.set(section, option, value2)
assert parser._all_options[section][option] == value2
assert parser._config[section]["body"][0]["is_multiline"] is False
assert parser._config[section]["body"][0]["option"] == option
assert parser._config[section]["body"][0]["value"] == value2
assert parser._config[section]["body"][0]["_raw"] == f"{option}: {value2}\n"
def test_set_new_multiline_option(self, parser):
section, option, value = "section1", "option1", "value1\nvalue2\nvalue3"
parser.add_section(section)
parser.set(section, option, value)
assert parser._config[section]["body"][0]["is_multiline"] is True
assert parser._config[section]["body"][0]["option"] == option
values = ["value1", "value2", "value3"]
raw_values = [" value1\n", " value2\n", " value3\n"]
assert parser._config[section]["body"][0]["value"] == values
assert parser._config[section]["body"][0]["_raw"] == f"{option}:\n"
assert parser._config[section]["body"][0]["_raw_value"] == raw_values
assert parser._all_options[section][option] == values
def test_set_option_of_non_existing_section(self, parser):
with pytest.raises(NoSectionError):
parser.set("section1", "option1", "value1")
def test_remove_option(self, parser):
section, option, value = "section1", "option1", "value1"
parser.add_section(section)
parser.set(section, option, value)
parser.remove_option(section, option)
assert option not in parser._all_options[section]
assert option not in parser._config[section]["body"]
def test_remove_non_existing_option(self, parser):
parser.add_section("section1")
with pytest.raises(NoOptionError):
parser.remove_option("section1", "option1")
def test_remove_option_of_non_existing_section(self, parser):
with pytest.raises(NoSectionError):
parser.remove_option("section1", "option1")
def test_get_option(self, parser):
parser.add_section("section1")
parser.add_section("section2")
parser.set("section1", "option1", "value1")
parser.set("section2", "option2", "value2")
parser.set("section2", "option3", "value two")
assert parser.get("section1", "option1") == "value1"
assert parser.get("section2", "option2") == "value2"
assert parser.get("section2", "option3") == "value two"
def test_get_option_of_non_existing_section(self, parser):
with pytest.raises(NoSectionError):
parser.get("section1", "option1")
def test_get_option_of_non_existing_option(self, parser):
parser.add_section("section1")
with pytest.raises(NoOptionError):
parser.get("section1", "option1")
def test_get_option_fallback(self, parser):
parser.add_section("section1")
assert parser.get("section1", "option1", "fallback_value") == "fallback_value"
def test_get_options(self, parser):
parser.add_section("section1")
parser.set("section1", "option1", "value1")
parser.set("section1", "option2", "value2")
parser.set("section1", "option3", "value3")
options = {"option1": "value1", "option2": "value2", "option3": "value3"}
assert parser.options("section1") == options
def test_get_option_as_int(self, parser):
parser.add_section("section1")
parser.set("section1", "option1", "1")
option = parser.getint("section1", "option1")
assert isinstance(option, int) is True
def test_get_option_as_float(self, parser):
parser.add_section("section1")
parser.set("section1", "option1", "1.234")
option = parser.getfloat("section1", "option1")
assert isinstance(option, float) is True
@pytest.mark.parametrize(
"value",
["True", "true", "on", "1", "yes", "False", "false", "off", "0", "no"],
)
def test_get_option_as_boolean(self, parser, value):
parser.add_section("section1")
parser.set("section1", "option1", value)
option = parser.getboolean("section1", "option1")
assert isinstance(option, bool) is True

View File

@@ -0,0 +1,7 @@
not_empty
[also_not_empty]
#
;
;
#
option: value

View File

@@ -0,0 +1,39 @@
# ======================================================================= #
# Copyright (C) 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# https://github.com/dw-0/simple-config-parser #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from pathlib import Path
import pytest
from src.simple_config_parser.simple_config_parser import SimpleConfigParser
from tests.utils import load_testdata_from_file
BASE_DIR = Path(__file__).parent.joinpath("test_data")
MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("matching_data.txt")
NON_MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("non_matching_data.txt")
@pytest.fixture
def parser():
return SimpleConfigParser()
@pytest.mark.parametrize("line", load_testdata_from_file(MATCHING_TEST_DATA_PATH))
def test_match_line_comment(parser, line):
"""Test that a line matches the definition of a line comment"""
assert (
parser._match_empty_line(line) is True
), f"Expected line '{line}' to match line comment definition!"
@pytest.mark.parametrize("line", load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH))
def test_non_matching_line_comment(parser, line):
"""Test that a line does not match the definition of a line comment"""
assert (
parser._match_empty_line(line) is False
), f"Expected line '{line}' to not match line comment definition!"

View File

@@ -0,0 +1,28 @@
;[example_section]
#[example_section]
# [example_section]
; [example_section]
;[gcode_macro CANCEL_PRINT]
#[gcode_macro CANCEL_PRINT]
# [gcode_macro CANCEL_PRINT]
; [gcode_macro CANCEL_PRINT]
;[gcode_macro SET_PAUSE_NEXT_LAYER]
#[gcode_macro SET_PAUSE_NEXT_LAYER]
# [gcode_macro SET_PAUSE_NEXT_LAYER]
; [gcode_macro SET_PAUSE_NEXT_LAYER]
;[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
#[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
# [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
; [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
;[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
#[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
# [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
; [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
;[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
#[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
# [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
; [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
;[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
#[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
# [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
; [gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]

View File

@@ -0,0 +1,5 @@
not_a_comment: nono
[also not a comment]
not_a_comment: ; comment
not_a_comment: # comment

View File

@@ -0,0 +1,39 @@
# ======================================================================= #
# Copyright (C) 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# https://github.com/dw-0/simple-config-parser #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from pathlib import Path
import pytest
from src.simple_config_parser.simple_config_parser import SimpleConfigParser
from tests.utils import load_testdata_from_file
BASE_DIR = Path(__file__).parent.joinpath("test_data")
MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("matching_data.txt")
NON_MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("non_matching_data.txt")
@pytest.fixture
def parser():
return SimpleConfigParser()
@pytest.mark.parametrize("line", load_testdata_from_file(MATCHING_TEST_DATA_PATH))
def test_match_line_comment(parser, line):
"""Test that a line matches the definition of a line comment"""
assert (
parser._match_line_comment(line) is True
), f"Expected line '{line}' to match line comment definition!"
@pytest.mark.parametrize("line", load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH))
def test_non_matching_line_comment(parser, line):
"""Test that a line does not match the definition of a line comment"""
assert (
parser._match_line_comment(line) is False
), f"Expected line '{line}' to not match line comment definition!"

View File

@@ -0,0 +1,461 @@
baud: 250000
minimum_cruise_ratio: 0.5
square_corner_velocity: 5.0
full_steps_per_rotation: 200
position_min: 0
homing_speed: 5.0
homing_retract_dist: 5.0
kinematics: cartesian
kinematics: delta
minimum_z_position: 0
speed: 50
horizontal_move_z: 5
kinematics: deltesian
minimum_z_position: 0
min_angle: 5
slow_ratio: 3
kinematics: corexy
kinematics: corexz
kinematics: hybrid_corexy
kinematics: hybrid_corexz
kinematics: polar
kinematics: rotary_delta
minimum_z_position: 0
speed: 50
horizontal_move_z: 5
kinematics: winch
kinematics: none
max_velocity: 1
max_accel: 1
instantaneous_corner_velocity: 1.000
max_extrude_only_distance: 50.0
pressure_advance: 0.0
pressure_advance_smooth_time: 0.040
max_power: 1.0
pullup_resistor: 4700
smooth_time: 1.0
max_delta: 2.0
pwm_cycle_time: 0.100
min_extrude_temp: 170
speed: 50
horizontal_move_z: 5
probe_count: 3, 3
round_probe_count: 5
fade_start: 1.0
fade_end: 0.0
split_delta_z: .025
move_check_distance: 5.0
mesh_pps: 2, 2
algorithm: lagrange
bicubic_tension: .2
x_adjust: 0
y_adjust: 0
z_adjust: 0
speed: 50
horizontal_move_z: 5
horizontal_move_z: 5
probe_height: 0
speed: 50
probe_speed: 5
speed: 50
horizontal_move_z: 5
screw_thread: CW-M3
speed: 50
horizontal_move_z: 5
retries: 0
retry_tolerance: 0
speed: 50
horizontal_move_z: 5
max_adjust: 4
retries: 0
retry_tolerance: 0
speed: 50.0
z_hop_speed: 15.0
move_to_previous: False
axes: xyz
endstop_align_zero: False
description: G-Code macro
initial_duration: 0.0
timeout: 600
enable_force_move: False
recover_velocity: 50.
retract_length: 0
retract_speed: 20
unretract_extra_length: 0
unretract_speed: 10
resolution: 1.0
default_type: echo
default_prefix: echo:
shaper_freq_x: 0
shaper_freq_y: 0
shaper_type: mzv
damping_ratio_x: 0.1
damping_ratio_y: 0.1
spi_speed: 5000000
axes_map: x, y, z
rate: 3200
spi_speed: 5000000
axes_map: x, y, z
i2c_speed: 400000
axes_map: x, y, z
min_freq: 5
max_freq: 133.33
accel_per_hz: 75
hz_per_sec: 1
mcu: mcu
deactivate_on_each_sample: True
x_offset: 0.0
y_offset: 0.0
speed: 5.0
samples: 1
sampleretract_dist: 2.0
samples_result: average
samples_tolerance: 0.100
samples_toleranceretries: 0
pin_move_time: 0.680
stow_on_each_sample: True
probe_with_touch_mode: False
pin_up_reports_not_triggered: True
pin_up_touch_modereports_triggered: True
recovery_time: 0.4
sensor_type: ldc1612
speed: 50
horizontal_move_z: 5
calibrate_start_x: 20
calibrate_end_x: 200
calibrate_y: 112.5
max_error: 120
hysteresis: 5
heating_gain: 2
extruder_heating_z: 50.
max_validation_temp: 60.
pullup_resistor: 4700
inlineresistor: 0
adc_voltage: 5.0
voltage_offset: 0
sensor_type: PT1000
pullup_resistor: 4700
spi_speed: 4000000
tc_type: K
tc_use_50Hz_filter: False
tc_averaging_count: 1
rtd_nominal_r: 100
rtd_referencer: 430
rtd_num_of_wires: 2
rtd_use_50Hz_filter: False
sensor_type: BME280
sensor_type: AHT10
sensor_type: temperature_mcu
sensor_mcu: mcu
sensor_type: temperature_host
sensor_type: DS18B20
sensor_type: temperature_combined
max_power: 1.0
shutdown_speed: 0
cycle_time: 0.010
hardware_pwm: False
kick_start_time: 0.100
off_below: 0.0
tachometer_ppr: 2
tachometer_poll_interval: 0.0015
heater: extruder
heater_temp: 50.0
fan_speed: 1.0
fan_speed: 1.0
pid_deriv_time: 2.0
target_temp: 40.0
max_speed: 1.0
min_speed: 0.3
cycle_time: 0.010
hardware_pwm: False
initial_RED: 0.0
initial_GREEN: 0.0
initial_BLUE: 0.0
initial_WHITE: 0.0
color_order: GRB
initial_RED: 0.0
initial_GREEN: 0.0
initial_BLUE: 0.0
initial_WHITE: 0.0
initial_RED: 0.0
initial_GREEN: 0.0
initial_BLUE: 0.0
i2c_address: 98
initial_RED: 0.0
initial_GREEN: 0.0
initial_BLUE: 0.0
initial_WHITE: 0.0
i2c_address: 98
color_order: RGBW
initial_RED: 0.0
initial_GREEN: 0.0
initial_BLUE: 0.0
initial_WHITE: 0.0
maximum_servo_angle: 180
minimum_pulse_width: 0.001
maximum_pulse_width: 0.002
pwm: False
cycle_time: 0.100
hardware_pwm: False
cycle_time: 0.100
hardware_pwm: False
cycle_time: 0.100
interpolate: True
senseresistor: 0.110
stealthchop_threshold: 0
driver_MSLUT0: 2863314260
driver_MSLUT1: 1251300522
driver_MSLUT2: 608774441
driver_MSLUT3: 269500962
driver_MSLUT4: 4227858431
driver_MSLUT5: 3048961917
driver_MSLUT6: 1227445590
driver_MSLUT7: 4211234
driver_W0: 2
driver_W1: 1
driver_W2: 1
driver_W3: 1
driver_X1: 128
driver_X2: 255
driver_X3: 255
driver_START_SIN: 0
driver_START_SIN90: 247
driver_IHOLDDELAY: 8
driver_TPOWERDOWN: 0
driver_TBL: 1
driver_TOFF: 4
driver_HEND: 7
driver_HSTRT: 0
driver_VHIGHFS: 0
driver_VHIGHCHM: 0
driver_PWM_AUTOSCALE: True
driver_PWM_FREQ: 1
driver_PWM_GRAD: 4
driver_PWM_AMPL: 128
driver_SGT: 0
driver_SEMIN: 0
driver_SEUP: 0
driver_SEMAX: 0
driver_SEDN: 0
driver_SEIMIN: 0
driver_SFILT: 0
interpolate: True
sense_resistor: 0.110
stealthchop_threshold: 0
driver_MULTISTEP_FILT: True
driver_IHOLDDELAY: 8
driver_TPOWERDOWN: 20
driver_TBL: 2
driver_TOFF: 3
driver_HEND: 0
driver_HSTRT: 5
driver_PWM_AUTOGRAD: True
driver_PWM_AUTOSCALE: True
driver_PWM_LIM: 12
driver_PWM_REG: 8
driver_PWM_FREQ: 1
driver_PWM_GRAD: 14
driver_PWM_OFS: 36
interpolate: True
sense_resistor: 0.110
stealthchop_threshold: 0
driver_MULTISTEP_FILT: True
driver_IHOLDDELAY: 8
driver_TPOWERDOWN: 20
driver_TBL: 2
driver_TOFF: 3
driver_HEND: 0
driver_HSTRT: 5
driver_PWM_AUTOGRAD: True
driver_PWM_AUTOSCALE: True
driver_PWM_LIM: 12
driver_PWM_REG: 8
driver_PWM_FREQ: 1
driver_PWM_GRAD: 14
driver_PWM_OFS: 36
driver_SGTHRS: 0
driver_SEMIN: 0
driver_SEUP: 0
driver_SEMAX: 0
driver_SEDN: 0
driver_SEIMIN: 0
spi_speed: 4000000
interpolate: True
idle_current_percent: 100
driver_TBL: 2
driver_RNDTF: 0
driver_HDEC: 0
driver_CHM: 0
driver_HEND: 3
driver_HSTRT: 3
driver_TOFF: 4
driver_SEIMIN: 0
driver_SEDN: 0
driver_SEMAX: 0
driver_SEUP: 0
driver_SEMIN: 0
driver_SFILT: 0
driver_SGT: 0
driver_SLPH: 0
driver_SLPL: 0
driver_DISS2G: 0
driver_TS2G: 3
interpolate: True
rref: 12000
stealthchop_threshold: 0
driver_MSLUT0: 2863314260
driver_MSLUT1: 1251300522
driver_MSLUT2: 608774441
driver_MSLUT3: 269500962
driver_MSLUT4: 4227858431
driver_MSLUT5: 3048961917
driver_MSLUT6: 1227445590
driver_MSLUT7: 4211234
driver_W0: 2
driver_W1: 1
driver_W2: 1
driver_W3: 1
driver_X1: 128
driver_X2: 255
driver_X3: 255
driver_START_SIN: 0
driver_START_SIN90: 247
driver_OFFSET_SIN90: 0
driver_MULTISTEP_FILT: True
driver_IHOLDDELAY: 6
driver_IRUNDELAY: 4
driver_TPOWERDOWN: 10
driver_TBL: 2
driver_TOFF: 3
driver_HEND: 2
driver_HSTRT: 5
driver_FD3: 0
driver_TPFD: 4
driver_CHM: 0
driver_VHIGHFS: 0
driver_VHIGHCHM: 0
driver_DISS2G: 0
driver_DISS2VS: 0
driver_PWM_AUTOSCALE: True
driver_PWM_AUTOGRAD: True
driver_PWM_FREQ: 0
driver_FREEWHEEL: 0
driver_PWM_GRAD: 0
driver_PWM_OFS: 29
driver_PWM_REG: 4
driver_PWM_LIM: 12
driver_SGT: 0
driver_SEMIN: 0
driver_SEUP: 0
driver_SEMAX: 0
driver_SEDN: 0
driver_SEIMIN: 0
driver_SFILT: 0
driver_SG4_ANGLE_OFFSET: 1
interpolate: True
sense_resistor: 0.075
stealthchop_threshold: 0
driver_MSLUT0: 2863314260
driver_MSLUT1: 1251300522
driver_MSLUT2: 608774441
driver_MSLUT3: 269500962
driver_MSLUT4: 4227858431
driver_MSLUT5: 3048961917
driver_MSLUT6: 1227445590
driver_MSLUT7: 4211234
driver_W0: 2
driver_W1: 1
driver_W2: 1
driver_W3: 1
driver_X1: 128
driver_X2: 255
driver_X3: 255
driver_START_SIN: 0
driver_START_SIN90: 247
driver_MULTISTEP_FILT: True
driver_IHOLDDELAY: 6
driver_TPOWERDOWN: 10
driver_TBL: 2
driver_TOFF: 3
driver_HEND: 2
driver_HSTRT: 5
driver_FD3: 0
driver_TPFD: 4
driver_CHM: 0
driver_VHIGHFS: 0
driver_VHIGHCHM: 0
driver_DISS2G: 0
driver_DISS2VS: 0
driver_PWM_AUTOSCALE: True
driver_PWM_AUTOGRAD: True
driver_PWM_FREQ: 0
driver_FREEWHEEL: 0
driver_PWM_GRAD: 0
driver_PWM_OFS: 30
driver_PWM_REG: 4
driver_PWM_LIM: 12
driver_SGT: 0
driver_SEMIN: 0
driver_SEUP: 0
driver_SEMAX: 0
driver_SEDN: 0
driver_SEIMIN: 0
driver_SFILT: 0
driver_DRVSTRENGTH: 0
driver_BBMCLKS: 4
driver_BBMTIME: 0
driver_FILT_ISENSE: 0
i2c_address: 96
analog_pullup_resistor: 4700
lcd_type: hd44780
hd44780_protocol_init: True
lcd_type: hd44780_spi
hd44780_protocol_init: True
lcd_type: st7920
lcd_type: emulated_st7920
lcd_type: uc1701
vcomh: 0
invert: False
x_offset: 0
type: disabled
type: list
type: command
type: input
pause_on_runout: True
event_delay: 3.0
pause_delay: 0.5
detection_length: 7.0
default_nominal_filament_diameter: 1.75
max_difference: 0.2
measurement_delay: 100
cal_dia1: 1.50
cal_dia2: 2.00
raw_dia1: 9500
raw_dia2: 10500
default_nominal_filament_diameter: 1.75
max_difference: 0.200
measurement_delay: 70
enable: False
measurement_interval: 10
logging: False
min_diameter: 1.0
use_current_dia_while_delay: False
sensor_type: hx711
gain: A-128
sample_rate: 80
sensor_type: hx717
gain: A-128
sample_rate: 320
sensor_type: ads1220
spi_speed: 512000
gain: 128
sample_rate: 660
smooth_time: 2.0
enable_pin: !gpio0_20
standstill_power_down: False
baud: 115200
feedrate_splice: 0.8
feedrate_normal: 1.0
auto_load_speed: 2
auto_cancel_variation: 0.1
sample_period: 0.000400

View File

@@ -0,0 +1,37 @@
[section]
[section with spaces]
[section with spaces and comments] ; comment 1
[section with spaces and comments] # comment 2
indented_option: value
option_with_no_value:
another_option_with_no_value:
indented_option_with_no_value:
# position_min: 0
# homing_speed: 5.0
### this is a comment
; this is also a comment
# [section]
# [section with spaces]
# [section with spaces and comments] ; comment 1
;[section]
;[section with spaces]
;[section with spaces and comments] ; comment 1
# commented_option: value
#commented_option: value
;commented_option: value
; commented_option: value
#
;
option_1 :: value
option_1:: value
option_1 ::value
option_2 == value
option_2== value
option_2 ==value
option_1 := value
option_1:= value
option_1 :=value
option_2 := value
option_2:= value
option_2 :=value

View File

@@ -0,0 +1,39 @@
# ======================================================================= #
# Copyright (C) 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# https://github.com/dw-0/simple-config-parser #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from pathlib import Path
import pytest
from src.simple_config_parser.simple_config_parser import SimpleConfigParser
from tests.utils import load_testdata_from_file
BASE_DIR = Path(__file__).parent.joinpath("test_data")
MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("matching_data.txt")
NON_MATCHING_TEST_DATA_PATH = BASE_DIR.joinpath("non_matching_data.txt")
@pytest.fixture
def parser():
return SimpleConfigParser()
@pytest.mark.parametrize("line", load_testdata_from_file(MATCHING_TEST_DATA_PATH))
def test_match_option(parser, line):
"""Test that a line matches the definition of an option"""
assert (
parser._match_option(line) is True
), f"Expected line '{line}' to match option definition!"
@pytest.mark.parametrize("line", load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH))
def test_non_matching_option(parser, line):
"""Test that a line does not match the definition of an option"""
assert (
parser._match_option(line) is False
), f"Expected line '{line}' to not match option definition!"

View File

@@ -0,0 +1,15 @@
trusted_clients:
gcode:
cors_domains:
an_options_block_start_with_comment: ; this is a comment
an_options_block_start_with_comment: # this is a comment
options_block_start_with_comment:;this is a comment
options_block_start_with_comment :;this is a comment
options_block_start_with_comment:#this is a comment
options_block_start_with_comment :#this is a comment
parameter_temperature_(°C):
parameter_temperature_(°C)=
parameter_humidity_(%_RH):
parameter_humidity_(%_RH) :
parameter_spool_weight_(%):
parameter_spool_weight_(%) =

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