lib.ProjectConf: Add module

Introduce ProjectConf module to cleanly parse make/project.conf ini-like configuration files. The new class supports:

- ini-style sections with header comments - Key-value pairs with backslash line continuation - Quoted values preserving spaces and comment delimiters (#) inside - Inline comments outside of quotes - Comma-separated list values with quoted commas - Cached section parsing to avoid re-parsing the same section - .get_section() to return an entire section unparsed

Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-05-29 12:22:08 +02:00
commit 24558f2b58
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61

View file

@ -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)