diff --git a/kiauh/components/moonraker/utils/utils.py b/kiauh/components/moonraker/utils/utils.py index e01cf88..24eacef 100644 --- a/kiauh/components/moonraker/utils/utils.py +++ b/kiauh/components/moonraker/utils/utils.py @@ -123,7 +123,7 @@ def create_example_moonraker_conf( scp = SimpleConfigParser() scp.read_file(target) trusted_clients: List[str] = [ - f" {'.'.join(ip)}\n", + f"{'.'.join(ip)}", *scp.getvals("authorization", "trusted_clients"), ] diff --git a/kiauh/core/settings/kiauh_settings.py b/kiauh/core/settings/kiauh_settings.py index e002e56..17c0dd6 100644 --- a/kiauh/core/settings/kiauh_settings.py +++ b/kiauh/core/settings/kiauh_settings.py @@ -254,32 +254,34 @@ class KiauhSettings: section: str, option: str, getter: Callable[[str, str, T | None], T], - fallback: T = None, + fallback: T | None = None, silent: bool = False, - ) -> T: + ) -> T | None: if not self.__check_option_exists(section, option, fallback, silent): return fallback return getter(section, option, fallback) def __set_repo_state(self, section: str, repos: List[str]) -> List[Repository]: _repos: List[Repository] = [] - for repo in repos: - try: - if repo.strip().startswith("#") or repo.strip().startswith(";"): - continue - if "," in repo: - url, branch = repo.strip().split(",") + for raw in repos: + line = raw.strip() - if not branch: - branch = "master" + if not line or line.startswith("#") or line.startswith(";"): + continue + + try: + if "," in line: + url_part, branch_part = line.split(",") + url = url_part.strip() + branch = branch_part.strip() or "master" else: - url = repo.strip() + url = line branch = "master" # url must not be empty otherwise it's considered # as an unrecoverable, invalid configuration if not url: - raise InvalidValueError(section, "repositories", repo) + raise InvalidValueError(section, "repositories", line) _repos.append(Repository(url.strip(), branch.strip())) diff --git a/kiauh/core/submodules/simple_config_parser/README.md b/kiauh/core/submodules/simple_config_parser/README.md index b84b6c7..ff6d267 100644 --- a/kiauh/core/submodules/simple_config_parser/README.md +++ b/kiauh/core/submodules/simple_config_parser/README.md @@ -10,42 +10,10 @@ 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: Klippers auto-generated SAVE_CONFIG section that can be found at the very end of the config file +- SaveConfig Block: 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 deleted file mode 100644 index db9ecb3..0000000 --- a/kiauh/core/submodules/simple_config_parser/src/simple_config_parser/constants.py +++ /dev/null @@ -1,74 +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 # -# ======================================================================= # -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 4084779..81c8f71 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) 2024 Dominik Willner # +# Copyright (C) 2025 Dominik Willner # # # # https://github.com/dw-0/simple-config-parser # # # @@ -8,19 +8,89 @@ from __future__ import annotations +import re +from dataclasses import dataclass, field +from enum import Enum from pathlib import Path -from typing import Callable, Dict, List +from typing import Any, Callable, Dict, List, Set, Union -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 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*([#;].*)?$" ) +# 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() @@ -47,6 +117,7 @@ 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""" @@ -55,17 +126,81 @@ 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.current_section: str | None = None - self.current_opt_block: str | None = None - self.in_option_block: bool = False + + 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 def _match_section(self, line: str) -> bool: """Whether the given line matches the definition of a section""" @@ -79,6 +214,10 @@ 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 @@ -97,67 +236,112 @@ 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.current_opt_block = None - self.current_section = SECTION_RE.match(line).group(1) - self.config[self.current_section] = { - "header": line, - "elements": [] - } + self._reset_special_items() - 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 - }) + 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_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 - }) + if self._match_option(line): + self._reset_special_items() - 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 + 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._match_save_config_start(line): - self.current_opt_block = None - self.save_config_block.append(line) + if self._match_options_block_start(line): + self._reset_special_items() - elif self._match_save_config_content(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_empty_line(line) or self._match_line_comment(line): - self.current_opt_block = None + 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 - # 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) + if "#" in line: + value = line.split("#", 1)[0].strip() + elif ";" in line: + value = line.split(";", 1)[0].strip() else: - 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 - }) + 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()) def read_file(self, file: Path) -> None: """Read and parse a config file""" - with open(file, "r") as file: + self._config = [] + with open(file, "r", encoding="utf-8") as file: for line in file: self._parse_line(line) @@ -166,115 +350,72 @@ class SimpleConfigParser: if path is None: raise ValueError("File path cannot be None") - 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) + # first write the header + content: List[str] = list(self._header) - sections = self.get_sections() - for i, section in enumerate(sections): - f.write(self.config[section]["header"]) + # 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) - 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"]) + # then write the save config block + content.extend(self._save_config_block) - # 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"] + # ensure file ends with a newline + if content and not content[-1].endswith("\n"): + content.append("\n") - 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"] + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.writelines(content) - 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 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() 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: + def add_section(self, section: str) -> Section: """Add a new section to the config""" if section in self.get_sections(): raise DuplicateSectionError(section) - if len(self.get_sections()) >= 1: - self._check_set_section_spacing() + if not self._config: + new_sect = Section(name=section, raw=f"[{section}]\n") + self._config.append(new_sect) + return new_sect - self.config[section] = { - "header": f"[{section}]\n", - "elements": [] - } + 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()) - 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" - }) + new_sect = Section(name=section, raw=f"[{section}]\n") + self._config.append(new_sect) + return new_sect def remove_section(self, section: str) -> None: - """Remove a section from the config""" - self.config.pop(section, None) + """Remove a section from the config - 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 + 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 has_option(self, section: str, option: str) -> bool: """Check if an option exists in a section""" @@ -285,56 +426,141 @@ class SimpleConfigParser: 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): + + # when adding options, we add them to the first matching section + # if the section does not exist, we create it + section: Section = ( self.add_section(section) + if not self.has_section(section) + else next(s for s in self._config if s.name == section) + ) - # 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 + 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, + ) - # 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" - } + 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 = new_val + opt.raw = 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] else: - new_element = { - "type": LineType.OPTION.value, - "name": option, - "value": value, - "raw": f"{option}: {value}\n" - } + return None - # 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 + 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""" - elements.insert(insert_pos, new_element) + # 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 def remove_option(self, section: str, option: str) -> None: - """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 + """Remove an option from a section + + 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: str | _UNSET = _UNSET) -> str: """ @@ -344,22 +570,20 @@ class SimpleConfigParser: a fallback value. """ try: - if section not in self.get_sections(): - raise NoSectionError(section) - if option not in self.get_options(section): + opt = self._get_option(section, option) + if not isinstance(opt, Option): raise NoOptionError(option, section) - 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 "" + return opt.value if opt else "" except (NoSectionError, NoOptionError): if fallback is _UNSET: raise return fallback - def getvals(self, section: str, option: str, fallback: List[str] | _UNSET = _UNSET) -> 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 @@ -367,15 +591,11 @@ class SimpleConfigParser: a fallback value. """ try: - if section not in self.get_sections(): - raise NoSectionError(section) - if option not in self.get_options(section): + opt = self._get_option(section, option) + if not isinstance(opt, MultiLineOption): raise NoOptionError(option, section) - for element in self.config[section]["elements"]: - if element["type"] is LineType.OPTION_BLOCK.value and element["name"] == option: - return [val.strip() for val in element["value"] if val.strip()] - return [] + return [v.value for v in opt.values] if opt else [] except (NoSectionError, NoOptionError): if fallback is _UNSET: @@ -413,7 +633,7 @@ class SimpleConfigParser: section: str, option: str, conv: Callable[[str], int | float | bool], - fallback: _UNSET = _UNSET, + fallback: Any = _UNSET, ) -> int | float | bool: """Return the value of the given option in the given section as a converted value""" try: 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 new file mode 100644 index 0000000..da11e60 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/assets/test_gcode.cfg @@ -0,0 +1,14 @@ +# 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 new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..828db62 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/matching_data.txt @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..17f5675 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_data/non_matching_data.txt @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..bbf5cc6 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/line_matching/match_gcode_block_start/test_match_gcode_block_start.py @@ -0,0 +1,39 @@ +# ======================================================================= # +# 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 89d43f2..9347f67 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,5 +1,4 @@ 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 02da2de..709253a 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,3 +29,9 @@ 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 7656420..cf56236 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,75 +5,217 @@ # # # 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.constants import HEADER_IDENT, LineType -from src.simple_config_parser.simple_config_parser import SimpleConfigParser -from tests.utils import load_testdata_from_file +from src.simple_config_parser.simple_config_parser import ( + BlankLine, + CommentLine, + MLOptionValue, + MultiLineOption, + Option, + Section, + SimpleConfigParser, +) -BASE_DIR = Path(__file__).parent.parent.joinpath("assets") -TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") +ASSETS_DIR = Path(__file__).parent.parent / "assets" +TEST_CFG = ASSETS_DIR / "test_config_1.cfg" -@pytest.fixture -def parser(): - parser = SimpleConfigParser() - for line in load_testdata_from_file(TEST_DATA_PATH): - parser._parse_line(line) # noqa - - return parser +@pytest.fixture() +def parser() -> SimpleConfigParser: + p = SimpleConfigParser() + p.read_file(TEST_CFG) + return p -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 +# ----------------------------- Helper utils ----------------------------- # -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_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_header_parsing(parser): - header = parser.config[HEADER_IDENT] - assert isinstance(header, list) - assert len(header) > 0 +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_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 +# ------------------------------ Basic parsing --------------------------- # - 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:" - expected_values = [ - "# these are multi-line values", - "value_5_1", - "value_5_2 ; here is a comment", - "value_5_3" - ] - assert option_block["value"] == expected_values, ( - f"Expected values: {expected_values}, " - f"got: {option_block['value']}" +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 + ] + 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" + ) + + +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 new file mode 100644 index 0000000..cc2be9b --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/line_parsing/test_multiline_update_behavior.py @@ -0,0 +1,65 @@ +# ======================================================================= # +# 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 fdd77fa..f0c684a 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,17 +10,19 @@ 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) - for line in load_testdata_from_file(file_path): - parser._parse_line(line) # noqa - + parser.read_file(file_path) 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 new file mode 100644 index 0000000..5c7d6b7 --- /dev/null +++ b/kiauh/core/submodules/simple_config_parser/tests/public_api/test_gcode_block.py @@ -0,0 +1,27 @@ +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 1e6f5fe..be3086a 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,41 +8,33 @@ 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): +def test_get_options(parser: SimpleConfigParser): expected_options = { - "section_1": {"option_1"}, + "section_1": {"option_1", "option_1_1", "option_1_2", "option_1_3"}, "section_2": {"option_2"}, "section_3": {"option_3"}, "section_4": {"option_4"}, "section number 5": {"option_5", "multi_option", "option_5_1"}, } - - 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) - ) + for sect, opts in expected_options.items(): + assert opts.issubset(parser.get_options(sect)) 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" @@ -50,137 +42,69 @@ 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" - # 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_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 def test_getval_fallback(parser): - assert parser.getval("section_1", "option_128", "fallback") == "fallback" - assert parser.getval("section_1", "option_128", None) is None + assert parser.getval("section_1", "option_128", fallback="fallback") == "fallback" + with pytest.raises(NoOptionError): + parser.getval("section_1", "option_128") 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_getint(parser): - value = parser.getint("section_1", "option_1_2") - assert isinstance(value, int) +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_from_val(parser): +def test_type_conversion_errors(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_getfloat_from_int(parser): - value = parser.getfloat("section_1", "option_1_2") - assert isinstance(value, float) +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_boolean(parser): - with pytest.raises(ValueError): - parser.getfloat("section_1", "option_1_1") +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_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" - +def test_set_multiline_option(parser): parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"]) - 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" + 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" def test_remove_option(parser): parser.remove_option("section_1", "option_1") - assert parser.has_option("section_1", "option_1") is False + assert not parser.has_option("section_1", "option_1") 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 f9272df..0b49833 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,16 +7,32 @@ # ======================================================================= # from pathlib import Path -from src.simple_config_parser.simple_config_parser import ( - SimpleConfigParser, -) +from src.simple_config_parser.simple_config_parser import Section, SimpleConfigParser -BASE_DIR = Path(__file__).parent.parent.joinpath("assets") -TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") +BASE_DIR = Path(__file__).parent.parent / "assets" +TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" -def test_read_file(): +def test_read_file_sections_and_header(): parser = SimpleConfigParser() parser.read_file(TEST_DATA_PATH) - assert parser.config is not None - assert parser.config.keys() is not None + + # 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) 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 0b731ce..400116d 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,16 +14,17 @@ from src.simple_config_parser.simple_config_parser import ( def test_get_sections(parser): - expected_keys = { + expected_core = { "section_1", "section_2", "section_3", "section_4", "section number 5", } - assert expected_keys.issubset( - parser.get_sections() - ), f"Expected keys: {expected_keys}, got: {parser.get_sections()}" + parsed = parser.get_sections() + assert expected_core.issubset(parsed), ( + f"Missing core sections: {expected_core - parsed}" + ) def test_has_section(parser): @@ -39,18 +40,6 @@ 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): @@ -62,4 +51,3 @@ 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 67b205b..bbd7f41 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,110 +9,91 @@ 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.joinpath("assets") -TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") -# TEST_DATA_PATH_2 = BASE_DIR.joinpath("test_config_1_write.cfg") +BASE_DIR = Path(__file__).parent.parent / "assets" +TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" def test_write_file_exception(): parser = SimpleConfigParser() with pytest.raises(ValueError): - parser.write_file(None) # noqa + parser.write_file(None) # noqa: intentionally invalid def test_write_to_file(tmp_path): - tmp_file = Path(tmp_path).joinpath("tmp_config.cfg") + tmp_file = Path(tmp_path) / "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() - assert parser2.config is not None + # 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" + ) - 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): - # 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") + 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" - # 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")) - # Compare with expected output - with open(expected_file, "r") as expected, open(output_file, "r") as actual: - assert expected.read() == actual.read() + assert output_file.read_text(encoding="utf-8") == expected_file.read_text( + encoding="utf-8" + ) - # 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): - # 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 +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" + 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")) - # Compare with expected output - with open(expected_file, "r") as expected, open(output_file, "r") as actual: - assert expected.read() == actual.read() + assert output_file.read_text(encoding="utf-8") == expected_file.read_text( + encoding="utf-8" + ) - # Additional verification parser2 = SimpleConfigParser() parser2.read_file(output_file) assert not parser2.has_section("section_to_remove") - assert "section_1" in parser2.get_sections() - assert "section_2" in parser2.get_sections() + assert {"section_1", "section_2"}.issubset(parser2.get_sections()) + def test_add_option_and_write(tmp_path): - # 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") + 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" - # 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")) - # Compare with expected output - with open(expected_file, "r") as expected, open(output_file, "r") as actual: - assert expected.read() == actual.read() + assert output_file.read_text(encoding="utf-8") == expected_file.read_text( + encoding="utf-8" + ) - # 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 893ecc1..7f80c77 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,80 +10,58 @@ 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") -TEST_DATA_PATH = BASE_DIR.joinpath("test_config_1.cfg") +BASE_DIR = Path(__file__).parent.parent / "assets" +TEST_DATA_PATH = BASE_DIR / "test_config_1.cfg" @pytest.fixture def parser(): - parser = SimpleConfigParser() - for line in load_testdata_from_file(TEST_DATA_PATH): - parser._parse_line(line) # noqa - - return parser + p = SimpleConfigParser() + p.read_file(TEST_DATA_PATH) + return p def test_get_int_conv(parser): - should_be_int = parser._get_conv("section_1", "option_1_2", int) - assert isinstance(should_be_int, int) + assert parser.getint("section_1", "option_1_2") == 5 def test_get_float_conv(parser): - should_be_float = parser._get_conv("section_1", "option_1_3", float) - assert isinstance(should_be_float, float) + assert pytest.approx(parser.getfloat("section_1", "option_1_3"), rel=1e-9) == 1.123 def test_get_bool_conv(parser): - should_be_bool = parser._get_conv( - "section_1", "option_1_1", parser._convert_to_boolean - ) - assert isinstance(should_be_bool, bool) + assert parser.getboolean("section_1", "option_1_1") is True def test_get_int_conv_fallback(parser): - 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 + assert parser.getint("section_1", "missing", fallback=128) == 128 + with pytest.raises(Exception): + parser.getint("section_1", "missing") def test_get_float_conv_fallback(parser): - 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 + assert parser.getfloat("section_1", "missing", fallback=1.234) == 1.234 + with pytest.raises(Exception): + parser.getfloat("section_1", "missing") def test_get_bool_conv_fallback(parser): - 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 - ) + assert parser.getboolean("section_1", "missing", fallback=True) is True + with pytest.raises(Exception): + parser.getboolean("section_1", "missing") def test_get_int_conv_exception(parser): with pytest.raises(ValueError): - parser._get_conv("section_1", "option_1", int) + parser.getint("section_1", "option_1") def test_get_float_conv_exception(parser): with pytest.raises(ValueError): - parser._get_conv("section_1", "option_1", float) + parser.getfloat("section_1", "option_1") def test_get_bool_conv_exception(parser): with pytest.raises(ValueError): - parser._get_conv("section_1", "option_1", parser._convert_to_boolean) + parser.getboolean("section_1", "option_1") 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 2f57762..d84d2bd 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.warn(f"Unable to create example config: {e}") + Logger.print_error(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 cddfa16..e1e8f45 100644 --- a/kiauh/utils/config_utils.py +++ b/kiauh/utils/config_utils.py @@ -48,7 +48,9 @@ def add_config_section( if options is not None: for option in reversed(options): - scp.set_option(section, option[0], option[1]) + opt_name = option[0] + opt_value = option[1] + scp.set_option(section, opt_name, opt_value) scp.write_file(cfg_file)