231 lines
8.0 KiB
Python
231 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
import shlex
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any, cast
|
|
|
|
import regex as re
|
|
|
|
from tools.docs_codegen.errors import make_docs_codegen_error
|
|
|
|
ScalarValue = str | int | float | bool | None
|
|
|
|
# Braced ``${VAR}`` template variables, mirroring runtime.py:TEMPLATE_VAR_RE.
|
|
TEMPLATE_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
|
|
|
|
def trim_blank_edges(lines: Sequence[str]) -> list[str]:
|
|
"""Drop leading and trailing blank/whitespace-only lines."""
|
|
start = 0
|
|
end = len(lines)
|
|
while start < end and not lines[start].strip():
|
|
start += 1
|
|
while end > start and not lines[end - 1].strip():
|
|
end -= 1
|
|
return list(lines[start:end])
|
|
|
|
|
|
def require_mapping(value: Any, *, field_name: str, block: Any) -> dict[str, Any]:
|
|
"""Require ``value`` to be a mapping, returning it with string-coerced keys."""
|
|
if not isinstance(value, dict):
|
|
raise make_docs_codegen_error(
|
|
f"converter field '{field_name}' must be a mapping, got {type(value).__name__}",
|
|
block=block,
|
|
)
|
|
return {str(key): item for key, item in value.items()}
|
|
|
|
|
|
def require_mapping_list(
|
|
yaml_root: Any,
|
|
*,
|
|
collection_name: str,
|
|
block: Any,
|
|
non_empty: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Require ``yaml_root[collection_name]`` to be a list of mappings.
|
|
|
|
Validates that the YAML root is a mapping, that ``collection_name`` holds a
|
|
list (optionally non-empty), and that every element is itself a mapping.
|
|
Shared by ``require_indexed_mapping`` and the external-DP converters so the
|
|
"named YAML list of mappings" pattern lives in one place.
|
|
"""
|
|
if not isinstance(yaml_root, dict):
|
|
raise make_docs_codegen_error(
|
|
f"YAML root must be a mapping, got {type(yaml_root).__name__}",
|
|
block=block,
|
|
)
|
|
collection = yaml_root.get(collection_name)
|
|
if not isinstance(collection, list) or (non_empty and not collection):
|
|
kind = "a non-empty list" if non_empty else "a list"
|
|
raise make_docs_codegen_error(
|
|
f"YAML field '{collection_name}' must be {kind}",
|
|
block=block,
|
|
)
|
|
return [
|
|
require_mapping(item, field_name=f"{collection_name}[{index}]", block=block)
|
|
for index, item in enumerate(collection)
|
|
]
|
|
|
|
|
|
def require_non_empty_string(value: Any, *, field_name: str, block: Any) -> str:
|
|
"""Require ``value`` to be a non-blank string, returning it stripped."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise make_docs_codegen_error(
|
|
f"converter field '{field_name}' must be a non-empty string",
|
|
block=block,
|
|
)
|
|
return value.strip()
|
|
|
|
|
|
def require_block_index(
|
|
*,
|
|
block: Any,
|
|
option_name: str,
|
|
default: int | None = None,
|
|
) -> int:
|
|
"""Read a non-negative integer directive option (e.g. ``case_index``) off the block."""
|
|
raw_index = block.get_option(option_name)
|
|
if raw_index is None:
|
|
if default is not None:
|
|
return default
|
|
raise make_docs_codegen_error(
|
|
f"model-code block with converter_tag '{block.converter_tag}' requires {option_name}",
|
|
block=block,
|
|
)
|
|
|
|
if not raw_index.isdecimal():
|
|
raise make_docs_codegen_error(
|
|
f"{option_name} must be a non-negative integer, got '{raw_index}'",
|
|
block=block,
|
|
)
|
|
return int(raw_index)
|
|
|
|
|
|
def require_indexed_mapping(
|
|
yaml_root: Any,
|
|
*,
|
|
collection_name: str,
|
|
option_name: str,
|
|
block: Any,
|
|
default_index: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Pick one mapping out of a YAML list, selected by a block directive option.
|
|
|
|
``collection_name`` is the YAML key holding the list (e.g. ``test_cases``) and
|
|
``option_name`` is the ``model-code`` directive option carrying the index
|
|
(e.g. ``case_index``).
|
|
"""
|
|
index = require_block_index(block=block, option_name=option_name, default=default_index)
|
|
|
|
collection = require_mapping_list(yaml_root, collection_name=collection_name, block=block)
|
|
if index >= len(collection):
|
|
raise make_docs_codegen_error(
|
|
f"{option_name} {index} is out of range for '{collection_name}' with {len(collection)} items",
|
|
block=block,
|
|
)
|
|
|
|
return collection[index]
|
|
|
|
|
|
def require_scalar_mapping(
|
|
value: Any,
|
|
*,
|
|
field_name: str,
|
|
block: Any,
|
|
) -> dict[str, ScalarValue]:
|
|
"""Require a mapping whose values are all scalars (no nested mappings/lists)."""
|
|
mapping = require_mapping(value, field_name=field_name, block=block)
|
|
normalized: dict[str, ScalarValue] = {}
|
|
for key, item in mapping.items():
|
|
if isinstance(item, (dict, list)):
|
|
raise make_docs_codegen_error(
|
|
f"converter field '{field_name}.{key}' must be a scalar value",
|
|
block=block,
|
|
)
|
|
normalized[str(key)] = cast(ScalarValue, item)
|
|
return normalized
|
|
|
|
|
|
def require_node_field(node: Mapping[str, object], field: str, *, node_index: int, block: Any) -> object:
|
|
"""Return a required ``config[node_index]`` field, erroring if it is missing."""
|
|
if node.get(field) is None:
|
|
raise make_docs_codegen_error(
|
|
f"config[{node_index}] is missing required field '{field}'",
|
|
block=block,
|
|
)
|
|
return node[field]
|
|
|
|
|
|
def parse_command_tokens(value: Any, *, field_name: str, block: Any) -> list[str]:
|
|
"""Normalize a shell string or flat token list into a list of argument tokens."""
|
|
if isinstance(value, str):
|
|
try:
|
|
return shlex.split(value, posix=True)
|
|
except ValueError as exc:
|
|
raise make_docs_codegen_error(
|
|
f"converter field '{field_name}' contains an invalid shell string: {exc}",
|
|
block=block,
|
|
) from exc
|
|
|
|
if isinstance(value, list) and all(not isinstance(item, (dict, list)) for item in value):
|
|
return [str(item) for item in value]
|
|
|
|
raise make_docs_codegen_error(
|
|
f"converter field '{field_name}' must be a shell string or a flat token list",
|
|
block=block,
|
|
)
|
|
|
|
|
|
def substitute_template_positionals(
|
|
value: str,
|
|
*,
|
|
positionals: Mapping[str, str],
|
|
) -> str:
|
|
"""Replace braced ``${VAR}`` template variables with positional shell params.
|
|
|
|
Only keys present in ``positionals`` are replaced; unknown braced variables
|
|
and unbraced references like ``$SERVER_PORT`` are left untouched.
|
|
"""
|
|
|
|
def repl(match: re.Match[str]) -> str:
|
|
key = match.group(1)
|
|
return positionals.get(key, match.group(0))
|
|
|
|
return TEMPLATE_VAR_RE.sub(repl, value)
|
|
|
|
|
|
def render_cli_command(
|
|
prefix: Sequence[str],
|
|
options: Sequence[tuple[str, Sequence[str]]],
|
|
*,
|
|
multiline: bool,
|
|
expand_values: bool = False,
|
|
) -> str:
|
|
"""Render a CLI command from a prefix and ``(flag, values)`` option groups.
|
|
|
|
Supports multi-value flags (e.g. ``--prefiller-hosts h1 h2``). With
|
|
``multiline=False`` the whole command is rendered on one line. With
|
|
``multiline=True`` each option starts on its own backslash-continued line;
|
|
when ``expand_values`` is also set, a multi-value flag is placed on its own
|
|
line followed by each value on its own indented line (single-value flags
|
|
stay inline). The returned string always ends with a newline.
|
|
"""
|
|
prefix_str = " ".join(prefix)
|
|
|
|
if not multiline:
|
|
rendered = [" ".join([flag, *[str(value) for value in values]]) for flag, values in options]
|
|
return " ".join([prefix_str, *rendered]).rstrip() + "\n"
|
|
|
|
# Each entry is a logical line rendered without its trailing backslash.
|
|
entries: list[str] = [prefix_str]
|
|
for flag, values in options:
|
|
str_values = [str(value) for value in values]
|
|
if expand_values and len(str_values) > 1:
|
|
entries.append(f" {flag}")
|
|
entries.extend(f" {value}" for value in str_values)
|
|
else:
|
|
entries.append(f" {' '.join([flag, *str_values])}")
|
|
|
|
lines = [entry + (" \\" if index < len(entries) - 1 else "") for index, entry in enumerate(entries)]
|
|
return "\n".join(lines) + "\n"
|