Parse every configuration entry without crossing assignment lines
This commit is contained in:
parent
034108e636
commit
eba404bc96
3 changed files with 52 additions and 22 deletions
|
|
@ -4,7 +4,7 @@ from typing import Dict, ItemsView, Iterator, KeysView, Mapping, Union, ValuesVi
|
||||||
|
|
||||||
from ..logger.get_logger import get_logger
|
from ..logger.get_logger import get_logger
|
||||||
from .parse_error import ParseError
|
from .parse_error import ParseError
|
||||||
from .pattern import pattern
|
from .pattern import ignored_line, pattern
|
||||||
|
|
||||||
logger = get_logger("ConfigFile")
|
logger = get_logger("ConfigFile")
|
||||||
|
|
||||||
|
|
@ -89,14 +89,25 @@ class ConfigFile(Mapping[str, str]):
|
||||||
with open(self._path, encoding="utf-8") as f:
|
with open(self._path, encoding="utf-8") as f:
|
||||||
lines: str = f.read()
|
lines: str = f.read()
|
||||||
|
|
||||||
matches = pattern.findall(lines)
|
position = 0
|
||||||
for key, *values in matches:
|
while position < len(lines):
|
||||||
try:
|
ignored = ignored_line.match(lines, position)
|
||||||
value = next(v for v in values if v)
|
if ignored is not None and ignored.end() > position:
|
||||||
except StopIteration:
|
position = ignored.end()
|
||||||
raise ParseError(f"""Cannot parse config file ({
|
continue
|
||||||
self._path.absolute()
|
match = pattern.match(lines, position)
|
||||||
}), error at key `{key}`""")
|
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
|
already_exists = key in self._key_values
|
||||||
if already_exists and not value.startswith(
|
if already_exists and not value.startswith(
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,16 @@ import re
|
||||||
|
|
||||||
pattern = re.compile(
|
pattern = re.compile(
|
||||||
r"""
|
r"""
|
||||||
(?:^|\n) # new key-value pairs must start on a new line
|
[\t ]*(?P<key>\w+)[\t ]*=[\t ]*
|
||||||
\s* # leading whitespace is allowed
|
(?:
|
||||||
(?!\#) # the key cannot start with a `#` symbol
|
"(?P<double>[^"]*)"
|
||||||
(\w+?) # then comes the key
|
| '(?P<single>[^']*)'
|
||||||
\s*=\s* # the key and value are separated by an equal sign
|
| `(?P<backtick>[^`]*)`
|
||||||
(?: # then comes the value
|
| (?!["'`])(?P<bare>[^\#\r\n]*?)
|
||||||
"([^"]*)" # 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
|
|
||||||
)
|
)
|
||||||
\s*(?:\#.*)? # comments can be added with the `#` symbol
|
[\t ]*(?:\#[^\r\n]*)?(?:\r?\n|$)
|
||||||
(?=\n|$) # a key-value pairs are separated by new lines
|
|
||||||
""",
|
""",
|
||||||
flags=re.UNICODE | re.VERBOSE,
|
flags=re.UNICODE | re.VERBOSE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ignored_line = re.compile(r"[\t ]*(?:\#[^\r\n]*)?(?:\r?\n|$)")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from great_ai.utilities import ConfigFile
|
from great_ai.utilities import ConfigFile, ParseError
|
||||||
|
|
||||||
DATA_PATH = Path(__file__).parent.resolve() / "data"
|
DATA_PATH = Path(__file__).parent.resolve() / "data"
|
||||||
|
|
||||||
|
|
@ -88,3 +88,25 @@ def test_env_exists_ignore_fallback() -> None:
|
||||||
def test_duplicate_key() -> None:
|
def test_duplicate_key() -> None:
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
ConfigFile(DATA_PATH / "bad.conf")
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue