135 lines
4.3 KiB
Python
135 lines
4.3 KiB
Python
|
|
"""Minimal YAML mapping loader for layer and PEP-stance declarations.
|
||
|
|
|
||
|
|
Stdlib only. Handles the subset this repository actually writes: nested
|
||
|
|
maps, lists of scalars, lists of maps, quoted strings, booleans, null,
|
||
|
|
integers, and empty lists. Not a general YAML implementation.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
|
||
|
|
def load_mapping(path: Path) -> dict[str, Any]:
|
||
|
|
data = load_mapping_text(path.read_text())
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise ValueError(f"{path} did not parse as a mapping")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
def load_mapping_text(text: str) -> dict[str, Any]:
|
||
|
|
data = _parse(text)
|
||
|
|
if not isinstance(data, dict):
|
||
|
|
raise ValueError("YAML text did not parse as a mapping")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
def _parse(text: str) -> Any:
|
||
|
|
lines: list[tuple[int, str]] = []
|
||
|
|
for raw in text.splitlines():
|
||
|
|
stripped = raw.split("#", 1)[0].rstrip()
|
||
|
|
if not stripped:
|
||
|
|
continue
|
||
|
|
indent = len(raw) - len(raw.lstrip(" "))
|
||
|
|
lines.append((indent, stripped.lstrip(" ")))
|
||
|
|
value, _ = _parse_block(lines, 0, 0)
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_block(
|
||
|
|
lines: list[tuple[int, str]], index: int, indent: int
|
||
|
|
) -> tuple[Any, int]:
|
||
|
|
if index >= len(lines):
|
||
|
|
return {}, index
|
||
|
|
current_indent, content = lines[index]
|
||
|
|
if current_indent < indent:
|
||
|
|
return {}, index
|
||
|
|
if content.startswith("- "):
|
||
|
|
return _parse_list(lines, index, current_indent)
|
||
|
|
return _parse_map(lines, index, current_indent)
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_map(
|
||
|
|
lines: list[tuple[int, str]], index: int, indent: int
|
||
|
|
) -> tuple[dict[str, Any], int]:
|
||
|
|
result: dict[str, Any] = {}
|
||
|
|
while index < len(lines):
|
||
|
|
current_indent, content = lines[index]
|
||
|
|
if current_indent < indent:
|
||
|
|
break
|
||
|
|
if current_indent > indent:
|
||
|
|
raise ValueError(f"unexpected indent at {content!r}")
|
||
|
|
if content.startswith("- "):
|
||
|
|
break
|
||
|
|
key, separator, remainder = content.partition(":")
|
||
|
|
if not separator:
|
||
|
|
raise ValueError(f"expected key: value, got {content!r}")
|
||
|
|
key = _parse_scalar(key.strip())
|
||
|
|
if not isinstance(key, str):
|
||
|
|
key = str(key)
|
||
|
|
remainder = remainder.strip()
|
||
|
|
index += 1
|
||
|
|
if remainder in ("", "|", ">"):
|
||
|
|
if index < len(lines) and lines[index][0] > indent:
|
||
|
|
value, index = _parse_block(lines, index, lines[index][0])
|
||
|
|
else:
|
||
|
|
value = None
|
||
|
|
else:
|
||
|
|
value = _parse_scalar(remainder)
|
||
|
|
result[key] = value
|
||
|
|
return result, index
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_list(
|
||
|
|
lines: list[tuple[int, str]], index: int, indent: int
|
||
|
|
) -> tuple[list[Any], int]:
|
||
|
|
result: list[Any] = []
|
||
|
|
while index < len(lines):
|
||
|
|
current_indent, content = lines[index]
|
||
|
|
if current_indent < indent:
|
||
|
|
break
|
||
|
|
if current_indent > indent:
|
||
|
|
raise ValueError(f"unexpected indent at {content!r}")
|
||
|
|
if not content.startswith("- "):
|
||
|
|
break
|
||
|
|
item = content[2:].strip()
|
||
|
|
index += 1
|
||
|
|
if not item:
|
||
|
|
if index < len(lines) and lines[index][0] > indent:
|
||
|
|
value, index = _parse_block(lines, index, lines[index][0])
|
||
|
|
else:
|
||
|
|
value = None
|
||
|
|
result.append(value)
|
||
|
|
continue
|
||
|
|
if ":" in item and not item.startswith(("'", '"')):
|
||
|
|
key, _, remainder = item.partition(":")
|
||
|
|
mapping: dict[str, Any] = {key.strip(): _parse_scalar(remainder.strip())}
|
||
|
|
if index < len(lines) and lines[index][0] > indent:
|
||
|
|
nested, index = _parse_block(lines, index, lines[index][0])
|
||
|
|
if isinstance(nested, dict):
|
||
|
|
mapping.update(nested)
|
||
|
|
result.append(mapping)
|
||
|
|
else:
|
||
|
|
result.append(_parse_scalar(item))
|
||
|
|
return result, index
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_scalar(text: str) -> Any:
|
||
|
|
if text in ("", "~", "null", "Null", "NULL"):
|
||
|
|
return None
|
||
|
|
if text in ("true", "True"):
|
||
|
|
return True
|
||
|
|
if text in ("false", "False"):
|
||
|
|
return False
|
||
|
|
if text == "[]":
|
||
|
|
return []
|
||
|
|
if text == "{}":
|
||
|
|
return {}
|
||
|
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
|
||
|
|
return text[1:-1]
|
||
|
|
try:
|
||
|
|
return int(text)
|
||
|
|
except ValueError:
|
||
|
|
return text
|