| """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: |
| |
| 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(("'", '"')): |
| |
| key, _, val = item.partition(":") |
| entry = {key.strip(): _scalar(val)} |
| pos += 1 |
| |
| 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)) |
|
|