Files
computer-playbook/utils/yaml_bootstrap.py
T
kevinveenbirkenbachandClaude Opus 5 79ba511bb7 fix(env): keep the .env generator stdlib-only
The env generator runs on the bare bootstrap python, before any
dependency is installed. utils/distros.py reached PyYAML through
utils.cache.yaml, so `python -m cli.meta.env` died with
ModuleNotFoundError on every dev-runtime image that ships python3
without PyYAML.

utils/yaml_bootstrap.load_block reads the block-YAML subset the SPOT
files use with stdlib only. It raises BootstrapYamlError on anything it
cannot parse identically to yaml.safe_load, so meta/distros.yml fails
loud instead of drifting.

The per-line regex lint meant to guard this is gone: one hop of
indirection defeated it and it stayed green while the invariant was
broken. It is replaced by a test that runs the real generator with
every non-stdlib import blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:47:01 +02:00

126 lines
4.9 KiB
Python

"""Stdlib reader for the block-YAML subset the bootstrap SPOT files use.
This is a deliberate second YAML touchpoint outside ``utils.cache.yaml``:
the modules that read the distro SPOT must import on the bare bootstrap
python, which runs the ``.env`` generator before any dependency is
installed, so they cannot reach the PyYAML-backed cache.
Supported: nested block mappings, block sequences of scalars, plain and
double-quoted string scalars, full-line and trailing comments, a single
leading document marker. Every other construct raises
:class:`BootstrapYamlError`, so ``load_block(text)`` either equals
``yaml.safe_load(text)`` or fails loud.
The plain-scalar rule is deliberately narrower than YAML's: a plain scalar
must start with a letter, an underscore or a slash, and may then carry only
``[A-Za-z0-9_./+@:-]`` and spaces. That excludes every leading character
PyYAML's implicit resolvers key off, so no accepted plain scalar can come
back as an int, float, bool, timestamp or null.
"""
from __future__ import annotations
import re
_PLAIN = re.compile(r"^[A-Za-z_/][A-Za-z0-9_./+@:\- ]*$")
_RESERVED = re.compile(r"^(?:true|false|yes|no|on|off|y|n|null)$", re.IGNORECASE)
class BootstrapYamlError(ValueError):
"""Raised for a construct outside the supported block subset."""
def _plain(text: str, lineno: int) -> str:
if not _PLAIN.match(text) or ": " in text or text.endswith(":"):
raise BootstrapYamlError(f"line {lineno}: unsupported plain scalar {text!r}")
if _RESERVED.match(text):
raise BootstrapYamlError(f"line {lineno}: {text!r} is not a string, quote it")
return text
def _scalar(raw: str, lineno: int) -> str:
if raw.startswith('"'):
end = raw.find('"', 1)
if end < 0 or "\\" in raw[1:end]:
raise BootstrapYamlError(
f"line {lineno}: unsupported quoted scalar {raw!r}"
)
trailer = raw[end + 1 :].strip()
if trailer and not trailer.startswith("#"):
raise BootstrapYamlError(f"line {lineno}: trailing text after {raw!r}")
return raw[1:end]
return _plain(raw.split(" #", 1)[0].strip(), lineno)
def load_block(text: str) -> dict:
"""Parse *text* as a block mapping and return it.
Args:
text: YAML source restricted to the supported block subset.
Returns:
The root mapping, with every scalar as a ``str``.
Raises:
BootstrapYamlError: for any construct outside the subset.
"""
root: dict = {}
stack: list[tuple[int, object]] = [(0, root)]
pending: tuple[dict, str, int] | None = None
for lineno, line in enumerate(text.splitlines(), 1):
if "\t" in line:
raise BootstrapYamlError(f"line {lineno}: tab indentation")
body = line.strip()
if not body or body.startswith("#"):
continue
if body == "---":
if lineno != 1:
raise BootstrapYamlError(f"line {lineno}: second document")
continue
indent = len(line) - len(line.lstrip(" "))
if pending is not None:
parent, key, parent_indent = pending
if indent <= parent_indent:
raise BootstrapYamlError(
f"line {lineno}: {key!r} has neither a value nor a block"
)
child: object = [] if body.startswith("- ") else {}
parent[key] = child
stack.append((indent, child))
pending = None
else:
while indent < stack[-1][0]:
stack.pop()
if indent != stack[-1][0]:
raise BootstrapYamlError(f"line {lineno}: unexpected indent")
container = stack[-1][1]
if body.startswith("- "):
if not isinstance(container, list):
raise BootstrapYamlError(f"line {lineno}: sequence entry in a mapping")
container.append(_scalar(body[2:], lineno))
continue
if isinstance(container, list):
raise BootstrapYamlError(f"line {lineno}: mapping entry in a sequence")
key, sep, rest = body.partition(": ")
if not sep:
if not body.endswith(":"):
raise BootstrapYamlError(
f"line {lineno}: not a mapping entry: {body!r}"
)
key, rest = body[:-1], ""
if key != key.strip():
raise BootstrapYamlError(f"line {lineno}: not a mapping entry: {body!r}")
_plain(key, lineno)
if key in container:
raise BootstrapYamlError(f"line {lineno}: duplicate key {key!r}")
rest = rest.strip()
if not rest or rest.startswith("#"):
pending = (container, key, indent)
continue
container[key] = _scalar(rest, lineno)
if pending is not None:
raise BootstrapYamlError(f"{pending[1]!r} has neither a value nor a block")
if not root:
raise BootstrapYamlError("empty document")
return root