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:
parent
bb228f1929
commit
24558f2b58
1 changed files with 238 additions and 0 deletions
238
src/python/jw/pkg/lib/ProjectConf.py
Normal file
238
src/python/jw/pkg/lib/ProjectConf.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue