File size: 4,251 Bytes
4554903 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | """Config loader for kintsugi_config.yaml.
Uses PyYAML when available. Falls back to a strict-subset parser so the
deployment target needs nothing beyond the stdlib. The subset covers
exactly what kintsugi_config.yaml uses: nested mappings by indentation,
`- item` lists, scalars (str/int/float/bool/null), quoted strings, and
`#` comments. No anchors, no multi-line strings, no flow collections.
"""
from pathlib import Path
from typing import Any
DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "kintsugi_config.yaml"
def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> dict:
text = Path(path).read_text()
try:
import yaml
return yaml.safe_load(text)
except ImportError:
return _parse_subset(text)
def _scalar(raw: str) -> Any:
raw = raw.strip()
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
return raw[1:-1]
if raw.startswith("'") and raw.endswith("'") and len(raw) >= 2:
return raw[1:-1]
low = raw.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
if low in ("null", "~", ""):
return None
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
return raw
def _strip_comment(line: str) -> str:
"""Remove a trailing comment, respecting quoted strings."""
in_s = in_d = False
for i, ch in enumerate(line):
if ch == "'" and not in_d:
in_s = not in_s
elif ch == '"' and not in_s:
in_d = not in_d
elif ch == "#" and not in_s and not in_d:
return line[:i]
return line
def _parse_subset(text: str) -> dict:
# Tokenize into (indent, content) lines, skipping blanks/comments.
lines: list[tuple[int, str]] = []
for raw in text.splitlines():
stripped = _strip_comment(raw).rstrip()
if not stripped.strip():
continue
indent = len(stripped) - len(stripped.lstrip(" "))
lines.append((indent, stripped.strip()))
root, _ = _parse_block(lines, 0, 0)
return root
def _parse_block(lines: list, pos: int, indent: int) -> tuple[Any, int]:
"""Parse a block starting at lines[pos] with the given indent level."""
if pos >= len(lines):
return {}, pos
if lines[pos][1].startswith("- "):
result_list: list = []
while pos < len(lines) and lines[pos][0] == indent and lines[pos][1].startswith("- "):
item = lines[pos][1][2:].strip()
if ":" in item and not item.startswith(("'", '"')):
# list of single-key mappings: "- key: value"
key, _, val = item.partition(":")
entry = {key.strip(): _scalar(val)}
pos += 1
# absorb continuation keys indented under the dash
while pos < len(lines) and lines[pos][0] > indent and not lines[pos][1].startswith("- "):
k, _, v = lines[pos][1].partition(":")
entry[k.strip()] = _scalar(v)
pos += 1
result_list.append(entry)
else:
result_list.append(_scalar(item))
pos += 1
return result_list, pos
result: dict = {}
while pos < len(lines):
line_indent, content = lines[pos]
if line_indent < indent:
break
if line_indent > indent:
raise ValueError(f"Unexpected indent in config near: {content!r}")
key, sep, val = content.partition(":")
if not sep:
raise ValueError(f"Expected 'key:' in config near: {content!r}")
key = key.strip()
val = val.strip()
if val:
result[key] = _scalar(val)
pos += 1
else:
pos += 1
if pos < len(lines) and lines[pos][0] > indent:
child, pos = _parse_block(lines, pos, lines[pos][0])
result[key] = child
else:
result[key] = None
return result, pos
if __name__ == "__main__":
import json
cfg = _parse_subset(Path(DEFAULT_CONFIG_PATH).read_text())
print(json.dumps(cfg, indent=2))
|