diff --git a/kiauh/core/submodules/simple_config_parser/README.md b/kiauh/core/submodules/simple_config_parser/README.md index ff6d267..b84b6c7 100644 --- a/kiauh/core/submodules/simple_config_parser/README.md +++ b/kiauh/core/submodules/simple_config_parser/README.md @@ -10,10 +10,42 @@ Specialized for handling Klipper style config files. - Section: A section is defined by a line starting with a `[` and ending with a `]` - Option: A line starting with a word, followed by a `:` or `=` and a value - Option Block: A line starting with a word, followed by a `:` or `=` and a newline - - The word `gcode` is excluded from being treated as an option block -- Gcode Block: A line starting with the word `gcode`, followed by a `:` or `=` and a newline - - All indented lines following the gcode line are considered part of the gcode block - Comment: A line starting with a `#` or `;` - Blank: A line containing only whitespace characters -- SaveConfig Block: Klippers auto-generated SAVE_CONFIG section that can be found at the very end of the config file +- SaveConfig: Klippers auto-generated SAVE_CONFIG section that can be found at the very end of the config file +--- + +### Internally, the config is stored as a dictionary of sections, each containing a header and a list of elements: +```python +config = { + "section_name": { + "header": "[section_name]\n", + "elements": [ + { + "type": "comment", + "content": "# This is a comment\n" + }, + { + "type": "option", + "name": "option1", + "value": "value1", + "raw": "option1: value1\n" + }, + { + "type": "blank", + "content": "\n" + }, + { + "type": "option_block", + "name": "option2", + "value": [ + "value2", + "value3" + ], + "raw": "option2:" + } + ] + } + } +``` diff --git a/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/constants.py b/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/constants.py new file mode 100644 index 0000000..db9ecb3 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/constants.py @@ -0,0 +1,74 @@ +# ======================================================================= # +# Copyright (C) 2024 Dominik Willner # +# # +# https://github.com/dw-0/simple-config-parser # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +import re +from enum import Enum + +# 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*$") + +SAVE_CONFIG_START_RE = re.compile(r"^#\*# <-+ SAVE_CONFIG -+>$") +SAVE_CONFIG_CONTENT_RE = re.compile(r"^#\*#.*$") + +BOOLEAN_STATES = { + "1": True, + "yes": True, + "true": True, + "on": True, + "0": False, + "no": False, + "false": False, + "off": False, +} + +HEADER_IDENT = "#_header" + +INDENT = " " * 4 + +class LineType(Enum): + OPTION = "option" + OPTION_BLOCK = "option_block" + COMMENT = "comment" + BLANK = "blank" diff --git a/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/simple_config_parser.py b/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/simple_config_parser.py index 56ade0a..4084779 100644 --- a/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/simple_config_parser.py +++ b/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/simple_config_parser.py @@ -1,5 +1,5 @@ # ======================================================================= # -# Copyright (C) 2025 Dominik Willner # +# Copyright (C) 2024 Dominik Willner # # # # https://github.com/dw-0/simple-config-parser # # # @@ -8,87 +8,20 @@ from __future__ import annotations -import re -from dataclasses import dataclass, field -from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set, Union +from typing import Callable, Dict, List -# definition of section line: -# - the 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 NOT be "gcode" -# - 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*gcode\s*[:=])([^;#:=\s]+)\s*[:=]\s*([#;].*)?$" +from ..simple_config_parser.constants import ( + BOOLEAN_STATES, + EMPTY_LINE_RE, + HEADER_IDENT, + LINE_COMMENT_RE, + OPTION_RE, + OPTIONS_BLOCK_START_RE, + SECTION_RE, LineType, INDENT, SAVE_CONFIG_START_RE, SAVE_CONFIG_CONTENT_RE, ) -# definition of gcode block start line: -# - the line MUST start with the word "gcode" -# - the word "gcode" 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 -GCODE_BLOCK_START_RE = re.compile(r"^\s*gcode\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*$") - -SAVE_CONFIG_START_RE = re.compile(r"^#\*# <-+ SAVE_CONFIG -+>$") -SAVE_CONFIG_CONTENT_RE = re.compile(r"^#\*#.*$") - -BOOLEAN_STATES = { - "1": True, - "yes": True, - "true": True, - "on": True, - "0": False, - "no": False, - "false": False, - "off": False, -} - - -class LineType(Enum): - OPTION = "option" - OPTION_BLOCK = "option_block" - COMMENT = "comment" - BLANK = "blank" +_UNSET = object() class NoSectionError(Exception): @@ -114,7 +47,6 @@ class NoOptionError(Exception): msg = f"Option '{option}' in section '{section}' is not defined" super().__init__(msg) - class UnknownLineError(Exception): """Raised when a line is not recognized as any known type""" @@ -123,81 +55,17 @@ class UnknownLineError(Exception): super().__init__(msg) -@dataclass -class Option: - """Dataclass representing a (pseudo) config option""" - - name: str - raw: str - value: str - - -@dataclass -class MultiLineOption: - """Dataclass representing a multi-line config option""" - - name: str - raw: str - values: List[MLOptionValue] = field(default_factory=list) - - -@dataclass -class MLOptionValue: - """Dataclass representing a value in a multi-line option""" - - raw: str - indent: int - value: str - - -@dataclass -class Gcode: - """Dataclass representing a gcode block""" - - name: str - raw: str - gcode: List[str] = field(default_factory=list) - - -@dataclass -class BlankLine: - """Dataclass representing a blank line""" - - raw: str = "\n" - - -@dataclass -class CommentLine: - """Dataclass representing a comment line""" - - raw: str - - -SectionItem = Union[Option, MultiLineOption, Gcode, BlankLine, CommentLine] - - -@dataclass -class Section: - """Dataclass representing a config section""" - - name: str - raw: str - items: List[SectionItem] = field(default_factory=list) - - # noinspection PyMethodMayBeStatic class SimpleConfigParser: """A customized config parser targeted at handling Klipper style config files""" def __init__(self) -> None: + self.header: List[str] = [] + self.save_config_block: List[str] = [] self.config: Dict = {} - - self._header: List[str] = [] - self._save_config_block: List[str] = [] - self._config: List[Section] = [] - self._curr_sect: Union[Section, None] = None - self._curr_ml_opt: Union[MultiLineOption, None] = None - self._curr_gcode: Union[Gcode, None] = None + self.current_section: str | None = None + self.current_opt_block: str | None = None + self.in_option_block: bool = False def _match_section(self, line: str) -> bool: """Whether the given line matches the definition of a section""" @@ -211,10 +79,6 @@ class SimpleConfigParser: """Whether the given line matches the definition of a multiline option""" return OPTIONS_BLOCK_START_RE.match(line) is not None - def _match_gcode_block_start(self, line: str) -> bool: - """Whether the given line matches the definition of a gcode block start""" - return GCODE_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 @@ -233,335 +97,246 @@ class SimpleConfigParser: def _parse_line(self, line: str) -> None: """Parses a line and determines its type""" - if self._curr_sect is None and not self._match_section(line): - # we are at the beginning of the file, so we consider the part - # up to the first section as the file header and store it separately - self._header.append(line) - return - if self._match_section(line): - self._reset_special_items() + self.current_opt_block = None + self.current_section = SECTION_RE.match(line).group(1) + self.config[self.current_section] = { + "header": line, + "elements": [] + } - sect_name: str = SECTION_RE.match(line).group(1) - sect = Section(name=sect_name, raw=line) - self._curr_sect = sect - self._config.append(sect) - return + elif self._match_option(line): + self.current_opt_block = None + option = OPTION_RE.match(line).group(1) + value = OPTION_RE.match(line).group(2) + self.config[self.current_section]["elements"].append({ + "type": LineType.OPTION.value, + "name": option, + "value": value, + "raw": line + }) - if self._match_option(line): - self._reset_special_items() + elif self._match_options_block_start(line): + option = OPTIONS_BLOCK_START_RE.match(line).group(1) + self.current_opt_block = option + self.config[self.current_section]["elements"].append({ + "type": LineType.OPTION_BLOCK.value, + "name": option, + "value": [], + "raw": line + }) - name: str = OPTION_RE.match(line).group(1) - val: str = OPTION_RE.match(line).group(2) - opt = Option( - name=name, - raw=line, - value=val, - ) - self._curr_sect.items.append(opt) - return + elif self.current_opt_block is not None: + # we are in an option block, so we add the line to the option's value + for element in reversed(self.config[self.current_section]["elements"]): + if element["type"] == LineType.OPTION_BLOCK.value and element["name"] == self.current_opt_block: + element["value"].append(line.strip()) # indentation is removed + break - if self._match_options_block_start(line): - self._reset_special_items() + elif self._match_save_config_start(line): + self.current_opt_block = None + self.save_config_block.append(line) - name: str = OPTIONS_BLOCK_START_RE.match(line).group(1) - ml_opt = MultiLineOption( - name=name, - raw=line, - ) - self._curr_ml_opt = ml_opt - self._curr_sect.items.append(ml_opt) - return + elif self._match_save_config_content(line): + self.current_opt_block = None + self.save_config_block.append(line) - if self._curr_ml_opt is not None: - # we are in an option block, so we consecutively add values - # to the current multiline option until we hit a different line type + elif self._match_empty_line(line) or self._match_line_comment(line): + self.current_opt_block = None - if "#" in line: - value = line.split("#", 1)[0].strip() - elif ";" in line: - value = line.split(";", 1)[0].strip() + # 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: - value = line.strip() - - ml_value = MLOptionValue( - raw=line, - indent=self._get_indent(line), - value=value, - ) - self._curr_ml_opt.values.append(ml_value) - return - - if self._match_gcode_block_start(line): - self._curr_gcode = Gcode( - name="gcode", - raw=line, - ) - self._curr_sect.items.append(self._curr_gcode) - return - - if self._curr_gcode is not None: - # we are in a gcode block, so we add any following line - # without further checks to the gcode block - self._curr_gcode.gcode.append(line) - return - - if self._match_save_config_start(line): - self._reset_special_items() - self._save_config_block.append(line) - return - - if self._match_save_config_content(line): - self._reset_special_items() - self._save_config_block.append(line) - return - - if self._match_empty_line(line): - self._reset_special_items() - self._curr_sect.items.append(BlankLine(raw=line)) - return - - if self._match_line_comment(line): - self._reset_special_items() - self._curr_sect.items.append(CommentLine(raw=line)) - return - - def _reset_special_items(self) -> None: - """Reset special items like current multine option and gcode block""" - self._curr_ml_opt = None - self._curr_gcode = None - - def _get_indent(self, line: str) -> int: - """Return the indentation level of a line""" - return len(line) - len(line.lstrip()) + element_type = LineType.BLANK.value if self._match_empty_line(line) else LineType.COMMENT.value + self.config[self.current_section]["elements"].append({ + "type": element_type, + "content": line + }) def read_file(self, file: Path) -> None: """Read and parse a config file""" - self._config = [] - with open(file, "r", encoding="utf-8") as file: + with open(file, "r") as file: for line in file: self._parse_line(line) - def write_file(self, path: Union[str, Path]) -> None: + def write_file(self, path: str | Path) -> None: """Write the config to a file""" if path is None: raise ValueError("File path cannot be None") - # first write the header - content: List[str] = list(self._header) + with open(path, "w", encoding="utf-8") as f: + if HEADER_IDENT in self.config: + for line in self.config[HEADER_IDENT]: + f.write(line) - # then write all sections - for i in self._config: - content.append(i.raw) - for item in i.items: - content.append(item.raw) - if isinstance(item, MultiLineOption): - content.extend(val.raw for val in item.values) - elif isinstance(item, Gcode): - content.extend(item.gcode) + sections = self.get_sections() + for i, section in enumerate(sections): + f.write(self.config[section]["header"]) - # then write the save config block - content.extend(self._save_config_block) + for element in self.config[section]["elements"]: + if element["type"] == LineType.OPTION.value: + f.write(element["raw"]) + elif element["type"] == LineType.OPTION_BLOCK.value: + f.write(element["raw"]) + for line in element["value"]: + f.write(INDENT + line.strip() + "\n") + elif element["type"] in [LineType.COMMENT.value, LineType.BLANK.value]: + f.write(element["content"]) + else: + raise UnknownLineError(element["raw"]) - # ensure file ends with a newline - if content and not content[-1].endswith("\n"): - content.append("\n") + # Ensure file ends with a single newline + if sections: # Only if we have any sections + last_section = sections[-1] + last_elements = self.config[last_section]["elements"] - with open(path, "w", encoding="utf-8", newline="\n") as f: - f.writelines(content) + if last_elements: + last_element = last_elements[-1] + if "raw" in last_element: + last_line = last_element["raw"] + else: # comment or blank line + last_line = last_element["content"] - def get_sections(self) -> Set[str]: - """Return a set of all section names""" - return {s.name for s in self._config} if self._config else set() + if not last_line.endswith("\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]: + """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) -> Section: + def add_section(self, section: str) -> None: """Add a new section to the config""" if section in self.get_sections(): raise DuplicateSectionError(section) - if not self._config: - new_sect = Section(name=section, raw=f"[{section}]\n") - self._config.append(new_sect) - return new_sect + if len(self.get_sections()) >= 1: + self._check_set_section_spacing() - last_sect: Section = self._config[-1] - if not last_sect.items or ( - last_sect.items and not isinstance(last_sect.items[-1], BlankLine) - ): - last_sect.items.append(BlankLine()) + self.config[section] = { + "header": f"[{section}]\n", + "elements": [] + } - new_sect = Section(name=section, raw=f"[{section}]\n") - self._config.append(new_sect) - return new_sect + def _check_set_section_spacing(self): + """Check if there is a blank line between the last section and the new section""" + prev_section_name: str = self.get_sections()[-1] + prev_section = self.config[prev_section_name] + prev_elements = prev_section["elements"] + + if prev_elements: + last_element = prev_elements[-1] + + # If the last element is a comment or blank line + if last_element["type"] in [LineType.COMMENT.value, LineType.BLANK.value]: + last_content = last_element["content"] + + # If the last element doesn't end with a newline, add one + if not last_content.endswith("\n"): + last_element["content"] += "\n" + + # If the last element is not a blank line, add a blank line + if last_content.strip() != "": + prev_elements.append({ + "type": "blank", + "content": "\n" + }) + else: + # If the last element is an option, add a blank line + prev_elements.append({ + "type": LineType.BLANK.value, + "content": "\n" + }) def remove_section(self, section: str) -> None: - """Remove a section from the config + """Remove a section from the config""" + self.config.pop(section, None) - This will remove ALL occurences of sections with the given name. - """ - self._config = [s for s in self._config if s.name != section] - - def get_options(self, section: str) -> Set[str]: - """Return a set of all option names for a given section""" - sections: List[Section] = [s for s in self._config if s.name == section] - all_items: List[SectionItem] = [ - item for section in sections for item in section.items - ] - - return {o.name for o in all_items if isinstance(o, (Option, MultiLineOption))} + def get_options(self, section: str) -> List[str]: + """Return a list of all option names for a given section""" + options = [] + if self.has_section(section): + for element in self.config[section]["elements"]: + if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value]: + options.append(element["name"]) + return options 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 set_option( - self, section: str, option: str, value: Union[str, List[str]] - ) -> None: + 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. """ - - # when adding options, we add them to the first matching section - # if the section does not exist, we create it - section: Section = ( + if not self.has_section(section): self.add_section(section) - if not self.has_section(section) - else next(s for s in self._config if s.name == section) - ) - opt = self._find_option_by_name(option, section=section) - if opt is None: - if isinstance(value, list): - indent = 4 - _opt = MultiLineOption( - name=option, - raw=f"{option}:\n", - values=[ - MLOptionValue( - raw=f"{' ' * indent}{val}\n", - indent=indent, - value=val, - ) - for val in value - ], - ) - else: - _opt = Option( - name=option, - raw=f"{option}: {value}\n", - value=value, - ) + # Check if option already exists + for element in self.config[section]["elements"]: + if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value] and element["name"] == option: + # Update existing option + if isinstance(value, list): + element["type"] = LineType.OPTION_BLOCK.value + element["value"] = value + element["raw"] = f"{option}:\n" + else: + element["type"] = LineType.OPTION.value + element["value"] = value + element["raw"] = f"{option}: {value}\n" + return - last_opt_idx: int = 0 - for idx, item in enumerate(section.items): - if isinstance(item, (Option, MultiLineOption)): - last_opt_idx = idx - # insert the new option after the last existing option - section.items.insert(last_opt_idx + 1, _opt) - - elif opt and isinstance(opt, Option) and isinstance(value, str): - curr_val = opt.value - new_val = value - opt.value = value - opt.raw.replace(curr_val, new_val) - - elif opt and isinstance(opt, MultiLineOption) and isinstance(value, list): - # note: we completely replace the existing values - # so any existing indentation, comments, etc. will be lost - indent = 4 - opt.values = [ - MLOptionValue( - raw=f"{' ' * indent}{val}\n", - indent=indent, - value=val, - ) - for val in value - ] - - def _find_section_by_name( - self, sect_name: str - ) -> Union[None, Section, List[Section]]: - """Find a section by name""" - _sects = [s for s in self._config if s.name == sect_name] - if len(_sects) > 1: - return _sects - elif len(_sects) == 1: - return _sects[0] + # Option doesn't exist, create new one + if isinstance(value, list): + new_element = { + "type": LineType.OPTION_BLOCK.value, + "name": option, + "value": value, + "raw": f"{option}:\n" + } else: - return None + new_element = { + "type": LineType.OPTION.value, + "name": option, + "value": value, + "raw": f"{option}: {value}\n" + } - def _find_option_by_name( - self, - opt_name: str, - section: Union[Section, None] = None, - sections: Union[List[Section], None] = None, - ) -> Union[None, Option, MultiLineOption]: - """Find an option or multi-line option by name in a section""" + # scan through elements to find the last option, after which we insert the new option + insert_pos = 0 + elements = self.config[section]["elements"] + for i, element in enumerate(elements): + if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value]: + insert_pos = i + 1 - # if a single section is provided, search its items for the option - if section is not None: - for item in section.items: - if ( - isinstance(item, (Option, MultiLineOption)) - and item.name == opt_name - ): - return item - - # if multiple sections with the same name are provided, merge their - # items and search for the option - if sections is not None: - all_items: List[SectionItem] = [ - item for sect in sections for item in sect.items - ] - for item in all_items: - if ( - isinstance(item, (Option, MultiLineOption)) - and item.name == opt_name - ): - return item - - return None + elements.insert(insert_pos, new_element) def remove_option(self, section: str, option: str) -> None: - """Remove an option from a section + """Remove an option from a section""" + if self.has_section(section): + elements = self.config[section]["elements"] + for i, element in enumerate(elements): + if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value] and element["name"] == option: + elements.pop(i) + break - This will remove the option from ALL occurences of sections with the given name. - Other non-option items (comments, blank lines, etc.) are preserved. - """ - sections: List[Section] = [s for s in self._config if s.name == section] - if not sections: - return - - for sect in sections: - sect.items = [ - item - for item in sect.items - if not ( - isinstance(item, (Option, MultiLineOption)) and item.name == option - ) - ] - - def _get_option( - self, section: str, option: str - ) -> Union[Option, MultiLineOption, None]: - """Internal helper to resolve an option or multi-line option.""" - if section not in self.get_sections(): - raise NoSectionError(section) - if option not in self.get_options(section): - raise NoOptionError(option, section) - sects: List[Section] = [s for s in self._config if s.name == section] - return ( - self._find_option_by_name(option, sections=sects) - if len(sects) > 1 - else self._find_option_by_name(option, section=sects[0]) - ) - - def getval(self, section: str, option: str, fallback: Optional[str] = None) -> str: + def getval(self, section: str, option: str, fallback: str | _UNSET = _UNSET) -> str: """ Return the value of the given option in the given section @@ -569,20 +344,22 @@ class SimpleConfigParser: a fallback value. """ try: - opt = self._get_option(section, option) - if not isinstance(opt, Option): + if section not in self.get_sections(): + raise NoSectionError(section) + if option not in self.get_options(section): raise NoOptionError(option, section) - return opt.value if opt else "" + for element in self.config[section]["elements"]: + if element["type"] is LineType.OPTION.value and element["name"] == option: + return str(element["value"].strip().replace("\n", "")) + return "" except (NoSectionError, NoOptionError): - if fallback is None: + if fallback is _UNSET: raise return fallback - def getvals( - self, section: str, option: str, fallback: Optional[List[str]] = None - ) -> List[str]: + def getvals(self, section: str, option: str, fallback: List[str] | _UNSET = _UNSET) -> List[str]: """ Return the values of the given multi-line option in the given section @@ -590,29 +367,33 @@ class SimpleConfigParser: a fallback value. """ try: - opt = self._get_option(section, option) - if not isinstance(opt, MultiLineOption): + if section not in self.get_sections(): + raise NoSectionError(section) + if option not in self.get_options(section): raise NoOptionError(option, section) - return [v.value for v in opt.values] if opt else [] + for element in self.config[section]["elements"]: + if element["type"] is LineType.OPTION_BLOCK.value and element["name"] == option: + return [val.strip() for val in element["value"] if val.strip()] + return [] except (NoSectionError, NoOptionError): - if fallback is None: + if fallback is _UNSET: raise return fallback - def getint(self, section: str, option: str, fallback: Optional[int] = None) -> int: + 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: Optional[float] = None + 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: Optional[bool] = None + 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( @@ -631,14 +412,14 @@ class SimpleConfigParser: self, section: str, option: str, - conv: Callable[[str], Union[int, float, bool]], - fallback: Optional[Any] = None, - ) -> Union[int, float, bool]: + 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.getval(section, option, fallback)) except (ValueError, TypeError, AttributeError) as e: - if fallback is not None: + if fallback is not _UNSET: return fallback raise ValueError( f"Cannot convert {self.getval(section, option)} to {conv.__name__}" diff --git a/kiauh/core/submodules/simple_config_parser/tests/assets/test_gcode.cfg b/kiauh/core/submodules/simple_config_parser/tests/assets/test_gcode.cfg deleted file mode 100644 index da11e60..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/assets/test_gcode.cfg +++ /dev/null @@ -1,14 +0,0 @@ -# Header line -# Another header comment -[toolhead] -option_a: 1 -option_b: true - -[gcode_macro test] -gcode: # start gcode block - G28 ; home all - M118 Done ; echo - G1 X10 Y10 F3000 - - - diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/__init__.py b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/matching_data.txt b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/matching_data.txt deleted file mode 100644 index 828db62..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/matching_data.txt +++ /dev/null @@ -1,16 +0,0 @@ -gcode: -gcode: -gcode: # comment -gcode: ; comment -gcode : -gcode : -gcode : # comment -gcode : ; comment -gcode= -gcode= -gcode= # comment -gcode= ; comment -gcode = -gcode = -gcode = # comment -gcode = ; comment diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/non_matching_data.txt b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/non_matching_data.txt deleted file mode 100644 index 17f5675..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/non_matching_data.txt +++ /dev/null @@ -1,39 +0,0 @@ -type: jsonfile -path: /dev/shm/drying_box.json -baud: 250000 -minimum_cruise_ratio: 0.5 -square_corner_velocity: 5.0 -full_steps_per_rotation: 200 -position_min: 0 -homing_speed: 5.0 -# baud: 250000 -# minimum_cruise_ratio: 0.5 -# square_corner_velocity: 5.0 -# full_steps_per_rotation: 200 -# position_min: 0 -# homing_speed: 5.0 - -option: -option : -option : -option= -option = -option = - - -### this is a comment -; this is also a comment -; -# -homing_speed:: -homing_speed:: -homing_speed :: -homing_speed :: -homing_speed== -homing_speed== -homing_speed == -homing_speed == -homing_speed := -homing_speed := -homing_speed =: -homing_speed =: diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_match_gcode_block_start.py b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_match_gcode_block_start.py deleted file mode 100644 index bbf5cc6..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_match_gcode_block_start.py +++ /dev/null @@ -1,39 +0,0 @@ -# ======================================================================= # -# Copyright (C) 2024 Dominik Willner # -# # -# 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_gcode_block_start(parser, line): - """Test that a line matches the definition of an options block start""" - assert parser._match_gcode_block_start(line) is True, ( - f"Expected line '{line}' to match gcode block start definition!" - ) - - -@pytest.mark.parametrize("line", load_testdata_from_file(NON_MATCHING_TEST_DATA_PATH)) -def test_non_matching_gcode_block_start(parser, line): - """Test that a line does not match the definition of an options block start""" - assert parser._match_gcode_block_start(line) is False, ( - f"Expected line '{line}' to not match gcode block start definition!" - ) diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/matching_data.txt b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/matching_data.txt index 9347f67..89d43f2 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/matching_data.txt +++ b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/matching_data.txt @@ -1,4 +1,5 @@ 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 diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/non_matching_data.txt b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/non_matching_data.txt index 709253a..02da2de 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/non_matching_data.txt +++ b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_option_block_start/test_data/non_matching_data.txt @@ -29,9 +29,3 @@ homing_speed := homing_speed := homing_speed =: homing_speed =: -gcode: -gcode : -gcode : -gcode= -gcode = -gcode = diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_line_parsing.py b/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_line_parsing.py index cf56236..7656420 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_line_parsing.py +++ b/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_line_parsing.py @@ -5,217 +5,75 @@ # # # This file may be distributed under the terms of the GNU GPLv3 license # # ======================================================================= # +import json from pathlib import Path -from typing import List import pytest -from src.simple_config_parser.simple_config_parser import ( - BlankLine, - CommentLine, - MLOptionValue, - MultiLineOption, - Option, - Section, - SimpleConfigParser, -) +from src.simple_config_parser.constants import HEADER_IDENT, LineType +from src.simple_config_parser.simple_config_parser import SimpleConfigParser +from tests.utils import load_testdata_from_file -ASSETS_DIR = Path(__file__).parent.parent / "assets" -TEST_CFG = ASSETS_DIR / "test_config_1.cfg" +BASE_DIR = Path(__file__).parent.parent.joinpath("assets") +TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") -@pytest.fixture() -def parser() -> SimpleConfigParser: - p = SimpleConfigParser() - p.read_file(TEST_CFG) - return p +@pytest.fixture +def parser(): + parser = SimpleConfigParser() + for line in load_testdata_from_file(TEST_DATA_PATH): + parser._parse_line(line) # noqa + + return parser -# ----------------------------- Helper utils ----------------------------- # +def test_section_parsing(parser): + expected_keys = {"section_1", "section_2", "section_3", "section_4"} + assert expected_keys.issubset( + parser.config.keys() + ), f"Expected keys: {expected_keys}, got: {parser.config.keys()}" + assert parser.in_option_block is False + assert parser.current_section == parser.get_sections()[-1] + assert parser.config["section_2"] is not None + assert parser.config["section_2"]["header"] == "[section_2] ; comment" + assert parser.config["section_2"]["elements"] is not None + assert len(parser.config["section_2"]["elements"]) > 0 -def _get_section(p: SimpleConfigParser, name: str) -> Section: - sect = [s for s in p._config if s.name == name] - assert sect, f"Section '{name}' not found" - return sect[0] +def test_option_parsing(parser): + assert parser.config["section_1"]["elements"][0]["type"] == LineType.OPTION.value + assert parser.config["section_1"]["elements"][0]["name"] == "option_1" + assert parser.config["section_1"]["elements"][0]["value"] == "value_1" + assert parser.config["section_1"]["elements"][0]["raw"] == "option_1: value_1" -def _get_option(sect: Section, name: str): - for item in sect.items: - if isinstance(item, (Option, MultiLineOption)) and item.name == name: - return item - return None +def test_header_parsing(parser): + header = parser.config[HEADER_IDENT] + assert isinstance(header, list) + assert len(header) > 0 -# ------------------------------ Basic parsing --------------------------- # +def test_option_block_parsing(parser): + section = "section number 5" + option_block = None + for element in parser.config[section]["elements"]: + if (element["type"] == LineType.OPTION_BLOCK.value and + element["name"] == "multi_option"): + option_block = element + break + assert option_block is not None, "multi_option block not found" + assert option_block["type"] == LineType.OPTION_BLOCK.value + assert option_block["name"] == "multi_option" + assert option_block["raw"] == "multi_option:" -def test_header_lines_preserved(parser: SimpleConfigParser): - # Lines before first section become header; ensure we captured them - assert parser._header, "Header should not be empty" - # The first section name should not appear inside header lines - assert all("[section_1]" not in ln for ln in parser._header) - # Ensure comments retained verbatim - assert any("a comment at the very top" in ln for ln in parser._header) - - -def test_section_names(parser: SimpleConfigParser): - expected = {"section_1", "section_2", "section_3", "section_4", "section number 5"} - assert parser.get_sections() == expected - - -def test_section_raw_line(parser: SimpleConfigParser): - s2 = _get_section(parser, "section_2") - assert s2.raw.startswith("[section_2]") - assert "; comment" in s2.raw - - -def test_single_line_option_parsing(parser: SimpleConfigParser): - s1 = _get_section(parser, "section_1") - opt = _get_option(s1, "option_1") - assert isinstance(opt, Option) - assert opt.name == "option_1" - assert opt.value == "value_1" - assert opt.raw.strip() == "option_1: value_1" - - -def test_other_single_line_option_values(parser: SimpleConfigParser): - s1 = _get_section(parser, "section_1") - bool_opt = _get_option(s1, "option_1_1") - int_opt = _get_option(s1, "option_1_2") - float_opt = _get_option(s1, "option_1_3") - assert isinstance(bool_opt, Option) and bool_opt.value == "True" - assert isinstance(int_opt, Option) and int_opt.value.startswith("5") - assert isinstance(float_opt, Option) and float_opt.value.startswith("1.123") - - -def test_comment_and_blank_lines_preserved(parser: SimpleConfigParser): - s4 = _get_section(parser, "section_4") - # Expect first item is a comment line, followed by an option - assert any(isinstance(i, CommentLine) for i in s4.items), "Comment line missing" - # Ensure at least one blank line exists in some section - assert any(isinstance(i, BlankLine) for s in parser._config for i in s.items), ( - "No blank lines parsed" - ) - - -def test_multiline_option_parsing(parser: SimpleConfigParser): - s5 = _get_section(parser, "section number 5") - ml = _get_option(s5, "multi_option") - assert isinstance(ml, MultiLineOption), "multi_option should be a MultiLineOption" - # Raw line ends with ':' - assert ml.raw.strip().startswith("multi_option:") - values: List[MLOptionValue] = ml.values - # Ensure values captured (includes comment lines inside block) - assert len(values) >= 4 - trimmed_values = [v.value for v in values] - # Comments are stripped from value field; original raw retains them - assert trimmed_values[0] == "" or "multi-line" not in trimmed_values[0], ( - "First value should be empty or comment stripped" - ) - assert "value_5_1" in trimmed_values - assert any("value_5_2" == v for v in trimmed_values) - assert any("value_5_3" == v for v in trimmed_values) - # Indentation should be consistent (4 spaces in test data) - assert all(v.indent == 4 for v in values), "Indentation should be 4 spaces" - - -def test_option_after_multiline_block(parser: SimpleConfigParser): - s5 = _get_section(parser, "section number 5") - opt = _get_option(s5, "option_5_1") - assert isinstance(opt, Option) - assert opt.value == "value_5_1" - - -def test_getval_and_conversions(parser: SimpleConfigParser): - assert parser.getval("section_1", "option_1") == "value_1" - assert parser.getboolean("section_1", "option_1_1") is True - assert parser.getint("section_1", "option_1_2") == 5 - assert abs(parser.getfloat("section_1", "option_1_3") - 1.123) < 1e-9 - - -def test_getval_fallback(parser: SimpleConfigParser): - assert parser.getval("missing_section", "missing", fallback="fb") == "fb" - assert parser.getint("missing_section", "missing", fallback=42) == 42 - - -def test_getvals_on_multiline_option(parser: SimpleConfigParser): - vals = parser.getvals("section number 5", "multi_option") - # Should not include inline comments, should capture cleaned values - assert any(v == "value_5_2" for v in vals) - - -def test_round_trip_write(tmp_path: Path, parser: SimpleConfigParser): - out_file = tmp_path / "round_trip.cfg" - parser.write_file(out_file) - original = TEST_CFG.read_text(encoding="utf-8") - written = out_file.read_text(encoding="utf-8") - # Files should match exactly (parser aims for perfect reproduction) - assert original == written, "Round-trip file content mismatch" - - -def test_set_option_adds_and_updates(parser: SimpleConfigParser): - # Add new option - parser.set_option("section_3", "new_opt", "some_value") - s3 = _get_section(parser, "section_3") - new_opt = _get_option(s3, "new_opt") - assert isinstance(new_opt, Option) and new_opt.value == "some_value" - # Update existing option value - parser.set_option("section_3", "new_opt", "other") - new_opt_after = _get_option(s3, "new_opt") - assert new_opt_after.value == "other" - - -def test_set_option_multiline(parser: SimpleConfigParser): - parser.set_option("section_2", "multi_new", ["a", "b", "c"]) - s2 = _get_section(parser, "section_2") - ml = _get_option(s2, "multi_new") - assert isinstance(ml, MultiLineOption) - assert [v.value for v in ml.values] == ["a", "b", "c"] - - -def test_remove_section(parser: SimpleConfigParser): - parser.remove_section("section_4") - assert "section_4" not in parser.get_sections() - - -def test_remove_option(parser: SimpleConfigParser): - parser.remove_option("section_1", "option_1") - s1 = _get_section(parser, "section_1") - assert _get_option(s1, "option_1") is None - - -def test_multiline_option_comment_stripping(parser: SimpleConfigParser): - # Ensure inline comments removed from value attribute but remain in raw - s5 = _get_section(parser, "section number 5") - ml = _get_option(s5, "multi_option") - assert isinstance(ml, MultiLineOption) - raw_with_comment = [v.raw for v in ml.values if "; here is a comment" in v.raw] - assert raw_with_comment, "Expected raw line with inline comment" - # Corresponding cleaned value should not contain the comment part - cleaned_match = [v.value for v in ml.values if v.value == "value_5_2"] - assert cleaned_match, "Expected cleaned value 'value_5_2' without comment" - - -def test_blank_lines_between_sections(parser: SimpleConfigParser): - # Ensure at least one blank line exists before section_2 (from original file structure) - idx_section_1 = [i for i, s in enumerate(parser._config) if s.name == "section_1"][ - 0 + expected_values = [ + "# these are multi-line values", + "value_5_1", + "value_5_2 ; here is a comment", + "value_5_3" ] - idx_section_2 = [i for i, s in enumerate(parser._config) if s.name == "section_2"][ - 0 - ] - # Collect lines after section_1 items end until next section raw - assert idx_section_2 == idx_section_1 + 1, "Sections not consecutive as expected" - # Validate section_2 has a preceding blank line inside previous section or header logic - s1 = _get_section(parser, "section_1") - assert any(isinstance(i, BlankLine) for i in s1.items), ( - "Expected blank line at end of section_1" + assert option_block["value"] == expected_values, ( + f"Expected values: {expected_values}, " + f"got: {option_block['value']}" ) - - -def test_write_preserves_trailing_newline(tmp_path: Path, parser: SimpleConfigParser): - out_file = tmp_path / "ensure_newline.cfg" - parser.write_file(out_file) - content = out_file.read_bytes() - assert content.endswith(b"\n"), "Written file must end with newline" diff --git a/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_multiline_update_behavior.py b/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_multiline_update_behavior.py deleted file mode 100644 index cc2be9b..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_multiline_update_behavior.py +++ /dev/null @@ -1,65 +0,0 @@ -# ======================================================================= # -# Tests: Verhalten beim Aktualisieren von Multiline-Optionen # -# ======================================================================= # -from pathlib import Path - -from src.simple_config_parser.simple_config_parser import ( - BlankLine, - MultiLineOption, - SimpleConfigParser, -) - -ASSETS_DIR = Path(__file__).parent.parent / "assets" -TEST_CFG = ASSETS_DIR / "test_config_1.cfg" - - -def test_update_existing_multiline_option_replaces_values_and_drops_comments(tmp_path): - parser = SimpleConfigParser() - parser.read_file(TEST_CFG) - - assert parser.getvals("section number 5", "multi_option") - orig_values = parser.getvals("section number 5", "multi_option") - assert "value_5_2" in orig_values - - new_values = ["alpha", "beta", "gamma"] - parser.set_option("section number 5", "multi_option", new_values) - - updated = parser.getvals("section number 5", "multi_option") - assert updated == new_values - - sect = [s for s in parser._config if s.name == "section number 5"][0] - ml = [ - i - for i in sect.items - if isinstance(i, MultiLineOption) and i.name == "multi_option" - ][0] - assert all("value_5_2" not in v.value for v in ml.values) - # Nach komplettem Replace keine alten Inline-Kommentare mehr - assert all("; here is a comment" not in v.raw for v in ml.values) - - out_file = tmp_path / "updated_multiline.cfg" - parser.write_file(out_file) - assert out_file.read_text(encoding="utf-8").endswith("\n") - - -def test_add_section_inserts_blank_line_if_needed(): - parser = SimpleConfigParser() - parser.read_file(TEST_CFG) - - last_before = parser._config[-1] - had_blank_before = bool(last_before.items) and isinstance( - last_before.items[-1], BlankLine - ) - - parser.add_section("new_last_section") - assert parser.has_section("new_last_section") - - # Vorherige letzte Section wurde ggf. um eine BlankLine erweitert - prev_last = [s for s in parser._config if s.name == last_before.name][0] - if not had_blank_before: - assert isinstance(prev_last.items[-2], BlankLine) or isinstance( - prev_last.items[-1], BlankLine - ) - else: - # Falls bereits BlankLine vorhanden war, bleibt sie bestehen - assert isinstance(prev_last.items[-1], BlankLine) diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/conftest.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/conftest.py index f0c684a..fdd77fa 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/conftest.py +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/conftest.py @@ -10,19 +10,17 @@ 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.parent.joinpath("assets") -CONFIG_FILES = [ - "test_config_1.cfg", - "test_config_2.cfg", - "test_config_3.cfg", - "test_config_4.cfg", -] +CONFIG_FILES = ["test_config_1.cfg", "test_config_2.cfg", "test_config_3.cfg", "test_config_4.cfg"] @pytest.fixture(params=CONFIG_FILES) def parser(request): parser = SimpleConfigParser() file_path = BASE_DIR.joinpath(request.param) - parser.read_file(file_path) + for line in load_testdata_from_file(file_path): + parser._parse_line(line) # noqa + return parser diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_gcode_block.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_gcode_block.py deleted file mode 100644 index 5c7d6b7..0000000 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_gcode_block.py +++ /dev/null @@ -1,27 +0,0 @@ -from pathlib import Path - -from src.simple_config_parser.simple_config_parser import Gcode, SimpleConfigParser - -ASSETS = Path(__file__).parent.parent / "assets" -GCODE_FILE = ASSETS / "test_gcode.cfg" - - -def test_gcode_block_parsing(): - parser = SimpleConfigParser() - parser.read_file(GCODE_FILE) - - assert "gcode_macro test" in parser.get_sections() - sect = [s for s in parser._config if s.name == "gcode_macro test"][0] - gcode_items = [i for i in sect.items if isinstance(i, Gcode)] - assert gcode_items, "No Gcode block found in section" - - gc = gcode_items[0] - assert gc.raw.strip().startswith("gcode:") - assert any("G28" in ln for ln in gc.gcode) - assert any("M118" in ln for ln in gc.gcode) - assert all(ln.startswith(" ") or ln == "\n" for ln in gc.gcode if ln.strip()) - - tmp_out = GCODE_FILE.parent / "tmp_gcode_roundtrip.cfg" - parser.write_file(tmp_out) - assert tmp_out.read_text(encoding="utf-8") == GCODE_FILE.read_text(encoding="utf-8") - tmp_out.unlink() diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_options_api.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_options_api.py index be3086a..1e6f5fe 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_options_api.py +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_options_api.py @@ -8,33 +8,41 @@ import pytest +from src.simple_config_parser.constants import LineType from src.simple_config_parser.simple_config_parser import ( - MultiLineOption, NoOptionError, NoSectionError, - SimpleConfigParser, ) -def test_get_options(parser: SimpleConfigParser): +def test_get_options(parser): expected_options = { - "section_1": {"option_1", "option_1_1", "option_1_2", "option_1_3"}, + "section_1": {"option_1"}, "section_2": {"option_2"}, "section_3": {"option_3"}, "section_4": {"option_4"}, "section number 5": {"option_5", "multi_option", "option_5_1"}, } - for sect, opts in expected_options.items(): - assert opts.issubset(parser.get_options(sect)) + + for section, options in expected_options.items(): + assert options.issubset( + parser.get_options(section) + ), f"Expected options: {options} in section: {section}, got: {parser.get_options(section)}" + assert "_raw" not in parser.get_options(section) + assert all( + not option.startswith("#_") for option in parser.get_options(section) + ) def test_has_option(parser): assert parser.has_option("section_1", "option_1") is True assert parser.has_option("section_1", "option_128") is False + # section does not exist: assert parser.has_option("section_128", "option_1") is False def test_getval(parser): + # test regular option values assert parser.getval("section_1", "option_1") == "value_1" assert parser.getval("section_3", "option_3") == "value_3" assert parser.getval("section_4", "option_4") == "value_4" @@ -42,69 +50,137 @@ def test_getval(parser): assert parser.getval("section number 5", "option_5_1") == "value_5_1" assert parser.getval("section_2", "option_2") == "value_2" - -def test_getvals_multiline(parser): - vals = parser.getvals("section number 5", "multi_option") - assert isinstance(vals, list) and len(vals) >= 3 - assert "value_5_2" in vals + # test multiline option values + ml_val = parser.getvals("section number 5", "multi_option") + assert isinstance(ml_val, list) + assert len(ml_val) > 0 def test_getval_fallback(parser): - assert parser.getval("section_1", "option_128", fallback="fallback") == "fallback" - with pytest.raises(NoOptionError): - parser.getval("section_1", "option_128") + assert parser.getval("section_1", "option_128", "fallback") == "fallback" + assert parser.getval("section_1", "option_128", None) is None def test_getval_exceptions(parser): with pytest.raises(NoSectionError): parser.getval("section_128", "option_1") + with pytest.raises(NoOptionError): parser.getval("section_1", "option_128") -def test_type_conversions(parser): - assert parser.getint("section_1", "option_1_2") == 5 - assert pytest.approx(parser.getfloat("section_1", "option_1_3"), rel=1e-9) == 1.123 - assert parser.getboolean("section_1", "option_1_1") is True +def test_getint(parser): + value = parser.getint("section_1", "option_1_2") + assert isinstance(value, int) -def test_type_conversion_errors(parser): +def test_getint_from_val(parser): with pytest.raises(ValueError): parser.getint("section_1", "option_1") + + +def test_getint_from_float(parser): + with pytest.raises(ValueError): + parser.getint("section_1", "option_1_3") + + +def test_getint_from_boolean(parser): + with pytest.raises(ValueError): + parser.getint("section_1", "option_1_1") + + +def test_getint_fallback(parser): + assert parser.getint("section_1", "option_128", 128) == 128 + assert parser.getint("section_1", "option_128", None) is None + + +def test_getboolean(parser): + value = parser.getboolean("section_1", "option_1_1") + assert isinstance(value, bool) + assert value is True or value is False + + +def test_getboolean_from_val(parser): with pytest.raises(ValueError): parser.getboolean("section_1", "option_1") + + +def test_getboolean_from_int(parser): + with pytest.raises(ValueError): + parser.getboolean("section_1", "option_1_2") + + +def test_getboolean_from_float(parser): + with pytest.raises(ValueError): + parser.getboolean("section_1", "option_1_3") + + +def test_getboolean_fallback(parser): + assert parser.getboolean("section_1", "option_128", True) is True + assert parser.getboolean("section_1", "option_128", False) is False + assert parser.getboolean("section_1", "option_128", None) is None + + +def test_getfloat(parser): + value = parser.getfloat("section_1", "option_1_3") + assert isinstance(value, float) + + +def test_getfloat_from_val(parser): with pytest.raises(ValueError): parser.getfloat("section_1", "option_1") -def test_type_conversion_fallbacks(parser): - assert parser.getint("section_1", "missing", fallback=99) == 99 - assert parser.getfloat("section_1", "missing", fallback=3.14) == 3.14 - assert parser.getboolean("section_1", "missing", fallback=False) is False +def test_getfloat_from_int(parser): + value = parser.getfloat("section_1", "option_1_2") + assert isinstance(value, float) -def test_set_option_creates_and_updates(parser): - parser.set_option("section_1", "new_option", "nv") - assert parser.getval("section_1", "new_option") == "nv" - parser.set_option("section_1", "new_option", "nv2") - assert parser.getval("section_1", "new_option") == "nv2" +def test_getfloat_from_boolean(parser): + with pytest.raises(ValueError): + parser.getfloat("section_1", "option_1_1") -def test_set_multiline_option(parser): +def test_getfloat_fallback(parser): + assert parser.getfloat("section_1", "option_128", 1.234) == 1.234 + assert parser.getfloat("section_1", "option_128", None) is None + + +def test_set_existing_option(parser): + parser.set_option("section_1", "new_option", "new_value") + assert parser.getval("section_1", "new_option") == "new_value" + assert parser.config["section_1"]["elements"][4] is not None + assert parser.config["section_1"]["elements"][4]["type"] == LineType.OPTION.value + assert parser.config["section_1"]["elements"][4]["name"] == "new_option" + assert parser.config["section_1"]["elements"][4]["value"] == "new_value" + assert parser.config["section_1"]["elements"][4]["raw"] == "new_option: new_value\n" + + +def test_set_new_option(parser): + parser.set_option("new_section", "very_new_option", "very_new_value") + assert ( + parser.has_section("new_section") is True + ), f"Expected 'new_section' in {parser.get_sections()}" + assert parser.getval("new_section", "very_new_option") == "very_new_value" + parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"]) - vals = parser.getvals("section_2", "array_option") - assert vals == ["value_1", "value_2", "value_3"] - # Prüfe Typ - sect = [s for s in parser._config if s.name == "section_2"][0] - ml = [ - i - for i in sect.items - if isinstance(i, MultiLineOption) and i.name == "array_option" - ][0] - assert isinstance(ml, MultiLineOption) - assert ml.raw == "array_option:\n" + assert parser.getvals("section_2", "array_option") == [ + "value_1", + "value_2", + "value_3", + ] + + assert parser.config["section_2"]["elements"][1] is not None + assert parser.config["section_2"]["elements"][1]["type"] == LineType.OPTION_BLOCK.value + assert parser.config["section_2"]["elements"][1]["name"] == "array_option" + assert parser.config["section_2"]["elements"][1]["value"] == [ + "value_1", + "value_2", + "value_3", + ] + assert parser.config["section_2"]["elements"][1]["raw"] == "array_option:\n" def test_remove_option(parser): parser.remove_option("section_1", "option_1") - assert not parser.has_option("section_1", "option_1") + assert parser.has_option("section_1", "option_1") is False diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_read_file.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_read_file.py index 0b49833..f9272df 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_read_file.py +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_read_file.py @@ -7,32 +7,16 @@ # ======================================================================= # from pathlib import Path -from src.simple_config_parser.simple_config_parser import Section, SimpleConfigParser +from src.simple_config_parser.simple_config_parser import ( + SimpleConfigParser, +) -BASE_DIR = Path(__file__).parent.parent / "assets" -TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" +BASE_DIR = Path(__file__).parent.parent.joinpath("assets") +TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") -def test_read_file_sections_and_header(): +def test_read_file(): parser = SimpleConfigParser() parser.read_file(TEST_DATA_PATH) - - # Header erhalten - assert parser._header, "Header darf nicht leer sein" - assert any("a comment at the very top" in ln for ln in parser._header) - - # Sektionen korrekt eingelesen - expected = {"section_1", "section_2", "section_3", "section_4", "section number 5"} - assert parser.get_sections() == expected - - # Reihenfolge bleibt erhalten - assert [s.name for s in parser._config] == [ - "section_1", - "section_2", - "section_3", - "section_4", - "section number 5", - ] - - # Jede Section ist ein Section-Dataclass - assert all(isinstance(s, Section) for s in parser._config) + assert parser.config is not None + assert parser.config.keys() is not None diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_sections_api.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_sections_api.py index 400116d..0b731ce 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_sections_api.py +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_sections_api.py @@ -14,17 +14,16 @@ from src.simple_config_parser.simple_config_parser import ( def test_get_sections(parser): - expected_core = { + expected_keys = { "section_1", "section_2", "section_3", "section_4", "section number 5", } - parsed = parser.get_sections() - assert expected_core.issubset(parsed), ( - f"Missing core sections: {expected_core - parsed}" - ) + assert expected_keys.issubset( + parser.get_sections() + ), f"Expected keys: {expected_keys}, got: {parser.get_sections()}" def test_has_section(parser): @@ -40,6 +39,18 @@ def test_add_section(parser): assert parser.has_section("new_section2") is True assert len(parser.get_sections()) == pre_add_count + 2 + new_section = parser.config["new_section"] + assert isinstance(new_section, dict) + assert new_section["header"] == "[new_section]\n" + assert new_section["elements"] is not None + assert new_section["elements"] == [] + + new_section2 = parser.config["new_section2"] + assert isinstance(new_section2, dict) + assert new_section2["header"] == "[new_section2]\n" + assert new_section2["elements"] is not None + assert new_section2["elements"] == [] + def test_add_section_duplicate(parser): with pytest.raises(DuplicateSectionError): @@ -51,3 +62,4 @@ def test_remove_section(parser): parser.remove_section("section_1") assert parser.has_section("section_1") is False assert len(parser.get_sections()) == pre_remove_count - 1 + assert "section_1" not in parser.config diff --git a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_write_file.py b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_write_file.py index bbd7f41..67b205b 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/public_api/test_write_file.py +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_write_file.py @@ -9,91 +9,110 @@ from pathlib import Path import pytest -from src.simple_config_parser.simple_config_parser import SimpleConfigParser +from src.simple_config_parser.simple_config_parser import ( + SimpleConfigParser, +) -BASE_DIR = Path(__file__).parent.parent / "assets" -TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" +BASE_DIR = Path(__file__).parent.parent.joinpath("assets") +TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") +# TEST_DATA_PATH_2 = BASE_DIR.joinpath("test_config_1_write.cfg") def test_write_file_exception(): parser = SimpleConfigParser() with pytest.raises(ValueError): - parser.write_file(None) # noqa: intentionally invalid + parser.write_file(None) # noqa def test_write_to_file(tmp_path): - tmp_file = Path(tmp_path) / "tmp_config.cfg" + tmp_file = Path(tmp_path).joinpath("tmp_config.cfg") parser1 = SimpleConfigParser() parser1.read_file(TEST_DATA_PATH) + # parser1.write_file(TEST_DATA_PATH_2) parser1.write_file(tmp_file) parser2 = SimpleConfigParser() parser2.read_file(tmp_file) assert tmp_file.exists() - # gleiche Sections & Round-Trip identisch - assert parser2.get_sections() == parser1.get_sections() - assert tmp_file.read_text(encoding="utf-8") == TEST_DATA_PATH.read_text( - encoding="utf-8" - ) + assert parser2.config is not None + with open(TEST_DATA_PATH, "r") as original, open(tmp_file, "r") as written: + assert original.read() == written.read() def test_remove_option_and_write(tmp_path): - test_dir = BASE_DIR / "write_tests" / "remove_option" - input_file = test_dir / "input.cfg" - expected_file = test_dir / "expected.cfg" - output_file = Path(tmp_path) / "output.cfg" + # Setup paths + test_dir = BASE_DIR.joinpath("write_tests/remove_option") + input_file = test_dir.joinpath("input.cfg") + expected_file = test_dir.joinpath("expected.cfg") + output_file = Path(tmp_path).joinpath("output.cfg") + # Read input file and remove option parser = SimpleConfigParser() parser.read_file(input_file) parser.remove_option("section_1", "option_to_remove") + + # Write modified config parser.write_file(output_file) + # parser.write_file(test_dir.joinpath("output.cfg")) - assert output_file.read_text(encoding="utf-8") == expected_file.read_text( - encoding="utf-8" - ) + # Compare with expected output + with open(expected_file, "r") as expected, open(output_file, "r") as actual: + assert expected.read() == actual.read() + # Additional verification parser2 = SimpleConfigParser() parser2.read_file(output_file) assert not parser2.has_option("section_1", "option_to_remove") - def test_remove_section_and_write(tmp_path): - test_dir = BASE_DIR / "write_tests" / "remove_section" - input_file = test_dir / "input.cfg" - expected_file = test_dir / "expected.cfg" - output_file = Path(tmp_path) / "output.cfg" + # Setup paths + test_dir = BASE_DIR.joinpath("write_tests/remove_section") + input_file = test_dir.joinpath("input.cfg") + expected_file = test_dir.joinpath("expected.cfg") + output_file = Path(tmp_path).joinpath("output.cfg") + # Read input file and remove section parser = SimpleConfigParser() parser.read_file(input_file) parser.remove_section("section_to_remove") + + # Write modified config parser.write_file(output_file) + # parser.write_file(test_dir.joinpath("output.cfg")) - assert output_file.read_text(encoding="utf-8") == expected_file.read_text( - encoding="utf-8" - ) + # Compare with expected output + with open(expected_file, "r") as expected, open(output_file, "r") as actual: + assert expected.read() == actual.read() + # Additional verification parser2 = SimpleConfigParser() parser2.read_file(output_file) assert not parser2.has_section("section_to_remove") - assert {"section_1", "section_2"}.issubset(parser2.get_sections()) - + assert "section_1" in parser2.get_sections() + assert "section_2" in parser2.get_sections() def test_add_option_and_write(tmp_path): - test_dir = BASE_DIR / "write_tests" / "add_option" - input_file = test_dir / "input.cfg" - expected_file = test_dir / "expected.cfg" - output_file = Path(tmp_path) / "output.cfg" + # Setup paths + test_dir = BASE_DIR.joinpath("write_tests/add_option") + input_file = test_dir.joinpath("input.cfg") + expected_file = test_dir.joinpath("expected.cfg") + output_file = Path(tmp_path).joinpath("output.cfg") + # Read input file and add option parser = SimpleConfigParser() parser.read_file(input_file) parser.set_option("section_1", "new_option", "new_value") + + # Write modified config parser.write_file(output_file) + # parser.write_file(test_dir.joinpath("output.cfg")) - assert output_file.read_text(encoding="utf-8") == expected_file.read_text( - encoding="utf-8" - ) + # Compare with expected output + with open(expected_file, "r") as expected, open(output_file, "r") as actual: + assert expected.read() == actual.read() + # Additional verification parser2 = SimpleConfigParser() parser2.read_file(output_file) assert parser2.has_option("section_1", "new_option") diff --git a/kiauh/core/submodules/simple_config_parser/tests/value_conversion/test_get_conv.py b/kiauh/core/submodules/simple_config_parser/tests/value_conversion/test_get_conv.py index 7f80c77..893ecc1 100644 --- a/kiauh/core/submodules/simple_config_parser/tests/value_conversion/test_get_conv.py +++ b/kiauh/core/submodules/simple_config_parser/tests/value_conversion/test_get_conv.py @@ -10,58 +10,80 @@ 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.parent / "assets" -TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" +BASE_DIR = Path(__file__).parent.parent.joinpath("assets") +TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") @pytest.fixture def parser(): - p = SimpleConfigParser() - p.read_file(TEST_DATA_PATH) - return p + parser = SimpleConfigParser() + for line in load_testdata_from_file(TEST_DATA_PATH): + parser._parse_line(line) # noqa + + return parser def test_get_int_conv(parser): - assert parser.getint("section_1", "option_1_2") == 5 + should_be_int = parser._get_conv("section_1", "option_1_2", int) + assert isinstance(should_be_int, int) def test_get_float_conv(parser): - assert pytest.approx(parser.getfloat("section_1", "option_1_3"), rel=1e-9) == 1.123 + should_be_float = parser._get_conv("section_1", "option_1_3", float) + assert isinstance(should_be_float, float) def test_get_bool_conv(parser): - assert parser.getboolean("section_1", "option_1_1") is True + should_be_bool = parser._get_conv( + "section_1", "option_1_1", parser._convert_to_boolean + ) + assert isinstance(should_be_bool, bool) def test_get_int_conv_fallback(parser): - assert parser.getint("section_1", "missing", fallback=128) == 128 - with pytest.raises(Exception): - parser.getint("section_1", "missing") + should_be_fallback_int = parser._get_conv( + "section_1", "option_128", int, fallback=128 + ) + assert isinstance(should_be_fallback_int, int) + assert should_be_fallback_int == 128 + assert parser._get_conv("section_1", "option_128", int, None) is None def test_get_float_conv_fallback(parser): - assert parser.getfloat("section_1", "missing", fallback=1.234) == 1.234 - with pytest.raises(Exception): - parser.getfloat("section_1", "missing") + should_be_fallback_float = parser._get_conv( + "section_1", "option_128", float, fallback=1.234 + ) + assert isinstance(should_be_fallback_float, float) + assert should_be_fallback_float == 1.234 + + assert parser._get_conv("section_1", "option_128", float, None) is None def test_get_bool_conv_fallback(parser): - assert parser.getboolean("section_1", "missing", fallback=True) is True - with pytest.raises(Exception): - parser.getboolean("section_1", "missing") + should_be_fallback_bool = parser._get_conv( + "section_1", "option_128", parser._convert_to_boolean, fallback=True + ) + assert isinstance(should_be_fallback_bool, bool) + assert should_be_fallback_bool is True + + assert ( + parser._get_conv("section_1", "option_128", parser._convert_to_boolean, None) + is None + ) def test_get_int_conv_exception(parser): with pytest.raises(ValueError): - parser.getint("section_1", "option_1") + parser._get_conv("section_1", "option_1", int) def test_get_float_conv_exception(parser): with pytest.raises(ValueError): - parser.getfloat("section_1", "option_1") + parser._get_conv("section_1", "option_1", float) def test_get_bool_conv_exception(parser): with pytest.raises(ValueError): - parser.getboolean("section_1", "option_1") + parser._get_conv("section_1", "option_1", parser._convert_to_boolean) diff --git a/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py b/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py index d84d2bd..2f57762 100644 --- a/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py +++ b/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py @@ -107,7 +107,7 @@ class GcodeShellCmdExtension(BaseExtension): shutil.copy(EXAMPLE_CFG_SRC, cfg_dir) Logger.print_ok("Done!") except OSError as e: - Logger.print_error(f"Unable to create example config: {e}") + Logger.warn(f"Unable to create example config: {e}") # backup each printer.cfg before modification svc = BackupService() diff --git a/kiauh/utils/config_utils.py b/kiauh/utils/config_utils.py index e1e8f45..cddfa16 100644 --- a/kiauh/utils/config_utils.py +++ b/kiauh/utils/config_utils.py @@ -48,9 +48,7 @@ def add_config_section( if options is not None: for option in reversed(options): - opt_name = option[0] - opt_value = option[1] - scp.set_option(section, opt_name, opt_value) + scp.set_option(section, option[0], option[1]) scp.write_file(cfg_file)