mirror of
https://github.com/dw-0/kiauh.git
synced 2025-10-26 06:56:07 +01:00
fix: do not drop SAVE_CONFIG block when editing and writing config files (#723)
Squashed 'kiauh/core/submodules/simple_config_parser/' changes from 4a6e5f2..f5eee99 f5eee99 feat: add support for parsing and handling `SAVE_CONFIG` blocks (#4) 8170583 refactor(api)!: `getval` now returns a string, `getvals` returns list of strings git-subtree-dir: kiauh/core/submodules/simple_config_parser git-subtree-split: f5eee99b0f04717c6bbf30c1256d70ad019223d5
This commit is contained in:
@@ -12,6 +12,7 @@ Specialized for handling Klipper style config files.
|
|||||||
- Option Block: A line starting with a word, followed by a `:` or `=` and a newline
|
- Option Block: A line starting with a word, followed by a `:` or `=` and a newline
|
||||||
- Comment: A line starting with a `#` or `;`
|
- Comment: A line starting with a `#` or `;`
|
||||||
- Blank: A line containing only whitespace characters
|
- Blank: A line containing only whitespace characters
|
||||||
|
- SaveConfig: Klippers auto-generated SAVE_CONFIG section that can be found at the very end of the config file
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ LINE_COMMENT_RE = re.compile(r"^\s*[#;].*")
|
|||||||
# - the line MUST contain only whitespace characters
|
# - the line MUST contain only whitespace characters
|
||||||
EMPTY_LINE_RE = re.compile(r"^\s*$")
|
EMPTY_LINE_RE = re.compile(r"^\s*$")
|
||||||
|
|
||||||
|
SAVE_CONFIG_START_RE = re.compile(r"^#\*# <-+ SAVE_CONFIG -+>$")
|
||||||
|
SAVE_CONFIG_CONTENT_RE = re.compile(r"^#\*#.*$")
|
||||||
|
|
||||||
BOOLEAN_STATES = {
|
BOOLEAN_STATES = {
|
||||||
"1": True,
|
"1": True,
|
||||||
"yes": True,
|
"yes": True,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ..simple_config_parser.constants import (
|
|||||||
LINE_COMMENT_RE,
|
LINE_COMMENT_RE,
|
||||||
OPTION_RE,
|
OPTION_RE,
|
||||||
OPTIONS_BLOCK_START_RE,
|
OPTIONS_BLOCK_START_RE,
|
||||||
SECTION_RE, LineType, INDENT,
|
SECTION_RE, LineType, INDENT, SAVE_CONFIG_START_RE, SAVE_CONFIG_CONTENT_RE,
|
||||||
)
|
)
|
||||||
|
|
||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
@@ -61,25 +61,34 @@ class SimpleConfigParser:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.header: List[str] = []
|
self.header: List[str] = []
|
||||||
|
self.save_config_block: List[str] = []
|
||||||
self.config: Dict = {}
|
self.config: Dict = {}
|
||||||
self.current_section: str | None = None
|
self.current_section: str | None = None
|
||||||
self.current_opt_block: str | None = None
|
self.current_opt_block: str | None = None
|
||||||
self.in_option_block: bool = False
|
self.in_option_block: bool = False
|
||||||
|
|
||||||
def _match_section(self, line: str) -> bool:
|
def _match_section(self, line: str) -> bool:
|
||||||
"""Wheter or not the given line matches the definition of a section"""
|
"""Whether the given line matches the definition of a section"""
|
||||||
return SECTION_RE.match(line) is not None
|
return SECTION_RE.match(line) is not None
|
||||||
|
|
||||||
def _match_option(self, line: str) -> bool:
|
def _match_option(self, line: str) -> bool:
|
||||||
"""Wheter or not the given line matches the definition of an option"""
|
"""Whether the given line matches the definition of an option"""
|
||||||
return OPTION_RE.match(line) is not None
|
return OPTION_RE.match(line) is not None
|
||||||
|
|
||||||
def _match_options_block_start(self, line: str) -> bool:
|
def _match_options_block_start(self, line: str) -> bool:
|
||||||
"""Wheter or not the given line matches the definition of a multiline option"""
|
"""Whether the given line matches the definition of a multiline option"""
|
||||||
return OPTIONS_BLOCK_START_RE.match(line) is not None
|
return OPTIONS_BLOCK_START_RE.match(line) is not None
|
||||||
|
|
||||||
|
def _match_save_config_start(self, line: str) -> bool:
|
||||||
|
"""Whether the given line matches the definition of a save config start"""
|
||||||
|
return SAVE_CONFIG_START_RE.match(line) is not None
|
||||||
|
|
||||||
|
def _match_save_config_content(self, line: str) -> bool:
|
||||||
|
"""Whether the given line matches the definition of a save config content"""
|
||||||
|
return SAVE_CONFIG_CONTENT_RE.match(line) is not None
|
||||||
|
|
||||||
def _match_line_comment(self, line: str) -> bool:
|
def _match_line_comment(self, line: str) -> bool:
|
||||||
"""Wheter or not the given line matches the definition of a comment"""
|
"""Whether the given line matches the definition of a comment"""
|
||||||
return LINE_COMMENT_RE.match(line) is not None
|
return LINE_COMMENT_RE.match(line) is not None
|
||||||
|
|
||||||
def _match_empty_line(self, line: str) -> bool:
|
def _match_empty_line(self, line: str) -> bool:
|
||||||
@@ -124,6 +133,14 @@ class SimpleConfigParser:
|
|||||||
element["value"].append(line.strip()) # indentation is removed
|
element["value"].append(line.strip()) # indentation is removed
|
||||||
break
|
break
|
||||||
|
|
||||||
|
elif self._match_save_config_start(line):
|
||||||
|
self.current_opt_block = None
|
||||||
|
self.save_config_block.append(line)
|
||||||
|
|
||||||
|
elif self._match_save_config_content(line):
|
||||||
|
self.current_opt_block = None
|
||||||
|
self.save_config_block.append(line)
|
||||||
|
|
||||||
elif self._match_empty_line(line) or self._match_line_comment(line):
|
elif self._match_empty_line(line) or self._match_line_comment(line):
|
||||||
self.current_opt_block = None
|
self.current_opt_block = None
|
||||||
|
|
||||||
@@ -185,6 +202,11 @@ class SimpleConfigParser:
|
|||||||
if not last_line.endswith("\n"):
|
if not last_line.endswith("\n"):
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
|
|
||||||
|
if self.save_config_block:
|
||||||
|
for line in self.save_config_block:
|
||||||
|
f.write(line)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
def get_sections(self) -> List[str]:
|
def get_sections(self) -> List[str]:
|
||||||
"""Return a list of all section names, but exclude any section starting with '#_'"""
|
"""Return a list of all section names, but exclude any section starting with '#_'"""
|
||||||
return list(
|
return list(
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 isn’t an object and shouldn’t 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 %}
|
||||||
|
|
||||||
|
#*# <---------------------- SAVE_CONFIG ---------------------->
|
||||||
|
#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated.
|
||||||
|
#*#
|
||||||
|
#*# [bed_mesh default]
|
||||||
|
#*# version = 1
|
||||||
|
#*# points =
|
||||||
|
#*# -0.152500, -0.133125, -0.113125, -0.159375, -0.232500
|
||||||
|
#*# -0.095000, -0.078750, -0.068125, -0.133125, -0.235000
|
||||||
|
#*# -0.092500, -0.040625, 0.004375, -0.077500, -0.193125
|
||||||
|
#*# -0.073750, 0.023750, 0.085625, 0.026875, -0.085000
|
||||||
|
#*# -0.140625, 0.038125, 0.126250, 0.097500, 0.003750
|
||||||
|
#*# tension = 0.2
|
||||||
|
#*# min_x = 26.0
|
||||||
|
#*# algo = bicubic
|
||||||
|
#*# y_count = 5
|
||||||
|
#*# mesh_y_pps = 2
|
||||||
|
#*# min_y = 5.0
|
||||||
|
#*# x_count = 5
|
||||||
|
#*# max_y = 174.0
|
||||||
|
#*# mesh_x_pps = 2
|
||||||
|
#*# max_x = 194.0
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#*# any content
|
||||||
|
#*#
|
||||||
|
#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated.
|
||||||
|
#*#
|
||||||
|
#*# [bed_mesh default]
|
||||||
|
#*# version = 1
|
||||||
|
#*# points =
|
||||||
|
#*# -0.152500, -0.133125, -0.113125, -0.159375, -0.232500
|
||||||
|
#*# -0.095000, -0.078750, -0.068125, -0.133125, -0.235000
|
||||||
|
#*# -0.092500, -0.040625, 0.004375, -0.077500, -0.193125
|
||||||
|
#*# -0.073750, 0.023750, 0.085625, 0.026875, -0.085000
|
||||||
|
#*# -0.140625, 0.038125, 0.126250, 0.097500, 0.003750
|
||||||
|
#*# tension = 0.2
|
||||||
|
#*# min_x = 26.0
|
||||||
|
#*# algo = bicubic
|
||||||
|
#*# y_count = 5
|
||||||
|
#*# mesh_y_pps = 2
|
||||||
|
#*# min_y = 5.0
|
||||||
|
#*# x_count = 5
|
||||||
|
#*# max_y = 174.0
|
||||||
|
#*# mesh_x_pps = 2
|
||||||
|
#*# max_x = 194.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#*# leading space prevents match
|
||||||
|
random
|
||||||
|
*# not starting with hash-star-hash
|
||||||
|
# *# spaced out
|
||||||
|
<- SAVE_CONFIG ->
|
||||||
|
;#*# semicolon first
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_lines(parser):
|
||||||
|
"""Alle Zeilen in matching_data.txt sollen als Save-Config-Content erkannt werden."""
|
||||||
|
matching_lines = load_testdata_from_file(MATCHING_TEST_DATA_PATH)
|
||||||
|
for line in matching_lines:
|
||||||
|
assert parser._match_save_config_content(line) is True, f"Line should be a save config content: {line!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_matching_lines(parser):
|
||||||
|
"""Alle Zeilen in non_matching_data.txt sollen NICHT als Save-Config-Content erkannt werden."""
|
||||||
|
non_matching_lines = load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH)
|
||||||
|
for line in non_matching_lines:
|
||||||
|
assert parser._match_save_config_content(line) is False, f"Line should not be a save config content: {line!r}"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#*# <- SAVE_CONFIG ->
|
||||||
|
#*# <---- SAVE_CONFIG ---->
|
||||||
|
#*# <------------------- SAVE_CONFIG ------------------->
|
||||||
|
#*# <---------------------- SAVE_CONFIG ---------------------->
|
||||||
|
#*# <----- SAVE_CONFIG ->
|
||||||
|
#*# <- SAVE_CONFIG ----------------->
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#*#<- SAVE_CONFIG ->
|
||||||
|
#*# <-SAVE_CONFIG ->
|
||||||
|
#*# <- SAVE_CONFIG->
|
||||||
|
#*# <- SAVE_CONFIG -> extra
|
||||||
|
#*# SAVE_CONFIG ---->
|
||||||
|
#*# < SAVE_CONFIG >
|
||||||
|
# *# <- SAVE_CONFIG ->
|
||||||
|
<- SAVE_CONFIG ->
|
||||||
|
random text
|
||||||
|
#*# <---------------------- SAVE_CONFIG ---------------------->
|
||||||
|
#*# <---------------------- SAVE_CONFIG ----------------------> #*#
|
||||||
|
#*# <-------------------------------------------->
|
||||||
|
#*# SAVE_CONFIG
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_lines(parser):
|
||||||
|
"""Test that all lines in the matching data file are correctly identified as save config start lines."""
|
||||||
|
matching_lines = load_testdata_from_file(MATCHING_TEST_DATA_PATH)
|
||||||
|
for line in matching_lines:
|
||||||
|
assert parser._match_save_config_start(line) is True, f"Line should be a save config start: {line!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_matching_lines(parser):
|
||||||
|
"""Test that all lines in the non-matching data file are correctly identified as not save config start lines."""
|
||||||
|
non_matching_lines = load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH)
|
||||||
|
for line in non_matching_lines:
|
||||||
|
assert parser._match_save_config_start(line) is False, f"Line should not be a save config start: {line!r}"
|
||||||
@@ -13,7 +13,7 @@ from src.simple_config_parser.simple_config_parser import SimpleConfigParser
|
|||||||
from tests.utils import load_testdata_from_file
|
from tests.utils import load_testdata_from_file
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.joinpath("assets")
|
BASE_DIR = Path(__file__).parent.parent.joinpath("assets")
|
||||||
CONFIG_FILES = ["test_config_1.cfg", "test_config_2.cfg", "test_config_3.cfg"]
|
CONFIG_FILES = ["test_config_1.cfg", "test_config_2.cfg", "test_config_3.cfg", "test_config_4.cfg"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=CONFIG_FILES)
|
@pytest.fixture(params=CONFIG_FILES)
|
||||||
|
|||||||
Reference in New Issue
Block a user