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)