From eba404bc96199dbcac16e50cf125564c71b4ff83 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 27 Oct 2026 20:02:11 +0000 Subject: [PATCH] Parse every configuration entry without crossing assignment lines --- great_ai/utilities/config_file/config_file.py | 29 +++++++++++++------ great_ai/utilities/config_file/pattern.py | 21 ++++++-------- tests/utilities/test_config_file.py | 24 ++++++++++++++- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/great_ai/utilities/config_file/config_file.py b/great_ai/utilities/config_file/config_file.py index 33e14b7..ef0ea12 100644 --- a/great_ai/utilities/config_file/config_file.py +++ b/great_ai/utilities/config_file/config_file.py @@ -4,7 +4,7 @@ from typing import Dict, ItemsView, Iterator, KeysView, Mapping, Union, ValuesVi from ..logger.get_logger import get_logger from .parse_error import ParseError -from .pattern import pattern +from .pattern import ignored_line, pattern logger = get_logger("ConfigFile") @@ -89,14 +89,25 @@ class ConfigFile(Mapping[str, str]): with open(self._path, encoding="utf-8") as f: lines: str = f.read() - matches = pattern.findall(lines) - for key, *values in matches: - try: - value = next(v for v in values if v) - except StopIteration: - raise ParseError(f"""Cannot parse config file ({ - self._path.absolute() - }), error at key `{key}`""") + position = 0 + while position < len(lines): + ignored = ignored_line.match(lines, position) + if ignored is not None and ignored.end() > position: + position = ignored.end() + continue + match = pattern.match(lines, position) + if match is None: + line = lines.count("\n", 0, position) + 1 + raise ParseError( + f"Cannot parse config file ({self._path.absolute()}) at line {line}" + ) + position = match.end() + key = match.group("key") + value = next( + match.group(name) + for name in ("double", "single", "backtick", "bare") + if match.group(name) is not None + ) already_exists = key in self._key_values if already_exists and not value.startswith( diff --git a/great_ai/utilities/config_file/pattern.py b/great_ai/utilities/config_file/pattern.py index ab5e056..a597dd5 100644 --- a/great_ai/utilities/config_file/pattern.py +++ b/great_ai/utilities/config_file/pattern.py @@ -2,19 +2,16 @@ import re pattern = re.compile( r""" - (?:^|\n) # new key-value pairs must start on a new line - \s* # leading whitespace is allowed - (?!\#) # the key cannot start with a `#` symbol - (\w+?) # then comes the key - \s*=\s* # the key and value are separated by an equal sign - (?: # then comes the value - "([^"]*)" # the value can be surrounded by quotes: "value" - | '([^']*)' # the value can be surrounded by quotes: 'value' - | `([^`]*)` # the value can be surrounded by quotes: `value` - | ([^#\n]*?) # or it is bare, in that case, the trailing whitespace is ignored + [\t ]*(?P\w+)[\t ]*=[\t ]* + (?: + "(?P[^"]*)" + | '(?P[^']*)' + | `(?P[^`]*)` + | (?!["'`])(?P[^\#\r\n]*?) ) - \s*(?:\#.*)? # comments can be added with the `#` symbol - (?=\n|$) # a key-value pairs are separated by new lines + [\t ]*(?:\#[^\r\n]*)?(?:\r?\n|$) """, flags=re.UNICODE | re.VERBOSE, ) + +ignored_line = re.compile(r"[\t ]*(?:\#[^\r\n]*)?(?:\r?\n|$)") diff --git a/tests/utilities/test_config_file.py b/tests/utilities/test_config_file.py index b3476c1..be219ae 100644 --- a/tests/utilities/test_config_file.py +++ b/tests/utilities/test_config_file.py @@ -2,7 +2,7 @@ import os from pathlib import Path import pytest -from great_ai.utilities import ConfigFile +from great_ai.utilities import ConfigFile, ParseError DATA_PATH = Path(__file__).parent.resolve() / "data" @@ -88,3 +88,25 @@ def test_env_exists_ignore_fallback() -> None: def test_duplicate_key() -> None: with pytest.raises(KeyError): ConfigFile(DATA_PATH / "bad.conf") + + +def test_empty_values_do_not_consume_the_next_assignment(tmp_path: Path) -> None: + path = tmp_path / "empty.conf" + path.write_text('empty=\nquoted=""\nnext=value\nmultiline="a\nb"\n') + assert dict(ConfigFile(path)) == { + "empty": "", + "quoted": "", + "next": "value", + "multiline": "a\nb", + } + + +@pytest.mark.parametrize( + "content", + ["broken syntax\nkey=value\n", 'key="unterminated\n', 'key="value" trailing\n'], +) +def test_malformed_configuration_is_rejected(tmp_path: Path, content: str) -> None: + path = tmp_path / "malformed.conf" + path.write_text(content) + with pytest.raises(ParseError): + ConfigFile(path)