diff --git a/src/python/jw/pkg/lib/ProjectConf.py b/src/python/jw/pkg/lib/ProjectConf.py new file mode 100644 index 00000000..c78b8f33 --- /dev/null +++ b/src/python/jw/pkg/lib/ProjectConf.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import re + +from pathlib import Path +from typing import ClassVar, Self + +class ProjectConf: + + class Error(ValueError): + pass + + __SECTION_RE: ClassVar[re.Pattern[str]] = re.compile( + r'^\s*\[([^\]]+)\]\s*(?:#.*)?$', + ) + + @classmethod + def __parse_key_value_section(cls, raw_section: str) -> dict[str, str]: + + ret: dict[str, str] = {} + pending_line: str | None = None + + for line_number, physical_line in enumerate( + raw_section.splitlines(), + start = 1, + ): + line = cls.__strip_comment_outside_quotes(physical_line).rstrip() + + if not line.strip() and pending_line is None: + continue + + is_continued = line.endswith('\\') + + if is_continued: + line = line[:-1] + + if pending_line is None: + pending_line = line + else: + pending_line += line.lstrip() + + if is_continued: + continue + + logical_line = pending_line.strip() + pending_line = None + + if not logical_line: + continue + + if '=' not in logical_line: + raise cls.Error( + f'Line {line_number} is not an assignment: {physical_line!r}', + ) + + key, value = logical_line.split('=', 1) + key = key.strip() + + if not key: + raise cls.Error(f'Line {line_number} has an empty key') + + ret[key] = value.strip() + + if pending_line is not None: + raise cls.Error('Section ends with an unfinished continuation') + + return ret + + @staticmethod + def __strip_comment_outside_quotes(line: str) -> str: + + in_quotes = False + + for index, char in enumerate(line): + if char == '"': + in_quotes = not in_quotes + continue + + if char != '#' or in_quotes: + continue + + return line[:index] + + return line + + @classmethod + def __split_value(cls, value: str) -> list[str]: + + if value == '': + return [] + + ret: list[str] = [] + start = 0 + in_quotes = False + + for index, char in enumerate(value): + if char == '"': + in_quotes = not in_quotes + continue + + if char != ',' or in_quotes: + continue + + elem = cls.__unquote(value[start:index].strip()) + if elem: + ret.append(elem) + start = index + 1 + + if in_quotes: + raise cls.Error(f'Unclosed quote in value: {value!r}') + + last = cls.__unquote(value[start:].strip()) + if last: + ret.append(last) + return ret + + @staticmethod + def __unquote(value: str) -> str: + + if len(value) < 2: + return value + + if value[0] != '"': + return value + + if value[-1] != '"': + return value + + return value[1:-1] + + # -- Public API + + def __init__(self, sections: dict[str, str]) -> None: + + self.__sections = sections + self.__parsed_cache: dict[str, dict[str, str]] = {} + + @classmethod + def read(cls, path: str) -> Self: + + text = Path(path).read_text(encoding = 'utf-8') + + sections: dict[str, list[str]] = {} + current_section: str | None = None + current_lines: list[str] = [] + + def __flush() -> None: + + nonlocal current_section, current_lines + + if current_section is not None: + sections.setdefault(current_section, []).append( + ''.join(current_lines), + ) + + current_lines = [] + + for line in text.splitlines(keepends = True): + match = cls.__SECTION_RE.match(line.rstrip('\r\n')) + + if not match: + if current_section is not None: + current_lines.append(line) + + continue + + __flush() + current_section = match.group(1).strip() + + __flush() + + ret = cls( + { + name: ''.join(section_parts) + for name, section_parts in sections.items() + } + ) + + return ret + + def get_section(self, section: str) -> str | None: + + return self.__sections.get(section) + + def get_str(self, section: str, key: str) -> str: + + ret = self.get_str_or_none(section, key) + + if ret is None: + raise self.Error(f'Missing key {section}.{key}') + + return ret + + def get_str_or_none(self, section: str, key: str) -> str | None: + + value = self.__get_value_or_none(section, key) + + if value is None: + return None + + return self.__unquote(value) + + def get_list(self, section: str, key: str) -> list[str]: + + ret = self.get_list_or_none(section, key) + + if ret is None: + raise self.Error(f'Missing key {section}.{key}') + + return ret + + def get_list_or_none(self, section: str, key: str) -> list[str] | None: + + value = self.__get_value_or_none(section, key) + + if value is None: + return None + + return self.__split_value(value) + + def __get_value_or_none(self, section: str, key: str) -> str | None: + + raw_section = self.__sections.get(section) + + if raw_section is None: + return None + + if section not in self.__parsed_cache: + try: + self.__parsed_cache[section] = self.__parse_key_value_section( + raw_section, + ) + except self.Error as exc: + raise self.Error( + f'Section {section!r} cannot be parsed as key-value pairs', + ) from exc + + return self.__parsed_cache[section].get(key)