mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 15:43:45 -06:00
scripts/generate_installer_doc.py rewrites the marked tables of wiki/installer.md: options from build_parser(), events from EVENT_KEYS, exit codes from the sys.exit calls, RETROBIOS_PAD values and the keys of each route from TUI_ROUTES, the one table the bar, the help and the stop line now read. Prose cells are kept, a row the code gains stops the script until its text is written, and --check runs in the tests. The site drops the markers. The full screen falls back to the line questions where the terminal's terminfo moves no cursor the ANSI way (vt52, glass terminals), and --tui names why. The line mode draws no escape code with TERM unset, dumb or such a terminal. Ctrl-Z typed on the screen stops the run as SIGTSTP does, the terminal handed back and redrawn on fg. A window too small names 40x12 and its own size. Pty tests check the modes, the cursor and the main screen after Ctrl-C, Ctrl-D, SIGTERM, SIGHUP and Ctrl-Z then fg. Tests from a mutmut run over the device filter, the card bounds and the WSL paths raise its score from 62/71 to 71/71. The parser lists its options in the page's order, with NAME, PATH, NAMES and N as arguments.
211 lines
8.3 KiB
Python
211 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Rewrite the marked tables of wiki/installer.md from install.py.
|
|
|
|
python scripts/generate_installer_doc.py rewrite the tables
|
|
python scripts/generate_installer_doc.py --check exit 1 when one is stale
|
|
|
|
Each table sits between `<!-- generated: NAME -->` and
|
|
`<!-- /generated: NAME -->`. What install.py knows is written from it: the
|
|
options and their arguments from its parser, the event kinds and their keys
|
|
from EVENT_KEYS, the exit codes from its sys.exit calls, the RETROBIOS_PAD
|
|
values and each route's keys from TUI_ROUTES. The prose cells (what an option
|
|
does, when an event comes) are kept as written, matched by their row. A row
|
|
install.py gains has no prose yet: the generator names it and writes nothing,
|
|
and the row is added by hand inside the markers, its first cell holding the
|
|
option, kind, code or value alone.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import importlib.util
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PAGE = ROOT / "wiki" / "installer.md"
|
|
INSTALLER = ROOT / "install.py"
|
|
|
|
_spec = importlib.util.spec_from_file_location("install", INSTALLER)
|
|
_install = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_install)
|
|
|
|
BLOCK = re.compile(
|
|
r"(?P<open><!-- generated: (?P<name>[a-z-]+) -->\n)(?P<body>.*?)"
|
|
r"(?P<close><!-- /generated: (?P=name) -->)", re.S)
|
|
_CELL = re.compile(r"(?<!\\)\|")
|
|
|
|
# The help screen's actions, as the page names them.
|
|
ACTIONS = {
|
|
"move": "Move",
|
|
"page": "Page",
|
|
"ok": "Continue, open, choose",
|
|
"tick": "Tick, untick",
|
|
"options": "Options",
|
|
"back": "Back, and quit on the first screen",
|
|
"stop": "Stop an install",
|
|
"kill": "Quit at once",
|
|
"enter": "Use the folder typed",
|
|
"esc": "Clear a filter, leave the folder typed",
|
|
}
|
|
# The two routes no launcher names, as the page heads their columns.
|
|
ROUTE_HEADINGS = {"keys": "Computer (`keys`)", "both": "Unknown (`both`)"}
|
|
|
|
|
|
class MissingProse(Exception):
|
|
"""Rows install.py has that the page does not describe yet."""
|
|
|
|
|
|
def _cells(line: str) -> list[str]:
|
|
return [cell.strip() for cell in _CELL.split(line.strip().strip("|"))]
|
|
|
|
|
|
def _rows(body: str) -> list[list[str]]:
|
|
lines = [line for line in body.splitlines() if line.startswith("|")]
|
|
return [_cells(line) for line in lines[2:]]
|
|
|
|
|
|
def _prose(body: str, key: re.Pattern[str], width: int) -> dict[str, list[str]]:
|
|
"""The prose cells of each row, by the key its first cell holds."""
|
|
kept = {}
|
|
for cells in _rows(body):
|
|
found = key.search(cells[0])
|
|
if found:
|
|
kept[found.group(1)] = (cells[1:] + [""] * width)[:width]
|
|
return kept
|
|
|
|
|
|
def _table(heading: list[str], rows: list[list[str]]) -> str:
|
|
lines = ["| " + " | ".join(heading) + " |",
|
|
"|" + "|".join("-" * (len(cell) + 2) for cell in heading) + "|"]
|
|
lines += ["| " + " | ".join(row) + " |" for row in rows]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _with_prose(name: str, facts: list[tuple[str, str]], body: str, key: str,
|
|
width: int) -> list[list[str]]:
|
|
"""Each row's first cell from install.py, the rest from the page, whose
|
|
last cell must be written."""
|
|
prose = _prose(body, re.compile(key), width)
|
|
missing = [fact for fact, _cell in facts if not prose.get(fact, [""])[-1]]
|
|
if missing:
|
|
raise MissingProse(f"{name}: no text for {', '.join(missing)}")
|
|
return [[cell, *prose[fact]] for fact, cell in facts]
|
|
|
|
|
|
def option_rows(body: str) -> list[list[str]]:
|
|
facts = []
|
|
# argparse's own --help last, after the options of the run.
|
|
for action in sorted(_install.build_parser()._actions,
|
|
key=lambda action: isinstance(action, argparse._HelpAction)):
|
|
flags = sorted(action.option_strings, key=lambda flag: not flag.startswith("--"))
|
|
metavar = "" if action.nargs == 0 else (action.metavar or action.dest.upper())
|
|
shown = ", ".join(f"`{flag} {metavar}`" if metavar else f"`{flag}`" for flag in flags)
|
|
facts.append((flags[0], f'<span id="option-{flags[0][2:]}"></span>{shown}'))
|
|
return _with_prose("options", facts, body, r"`(--[a-z-]+)", 1)
|
|
|
|
|
|
def exit_codes() -> list[int]:
|
|
"""The codes install.py exits with: its sys.exit calls, 0 for a run
|
|
that returns, 2 for an option argparse refuses."""
|
|
codes = {0, 2}
|
|
for node in ast.walk(ast.parse(INSTALLER.read_text(encoding="utf-8"))):
|
|
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "exit" and getattr(node.func.value, "id", "") == "sys"
|
|
and node.args):
|
|
continue
|
|
given = node.args[0]
|
|
for value in ((given.body, given.orelse) if isinstance(given, ast.IfExp) else (given,)):
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, int):
|
|
codes.add(value.value)
|
|
return sorted(codes)
|
|
|
|
|
|
def exit_rows(body: str) -> list[list[str]]:
|
|
facts = [(str(code), f"`{code}`") for code in exit_codes()]
|
|
return _with_prose("exit-codes", facts, body, r"`(\d+)`", 1)
|
|
|
|
|
|
def event_rows(body: str) -> list[list[str]]:
|
|
facts = [(kind, f"`{kind}`") for kind in _install.EVENT_KEYS]
|
|
rows = _with_prose("events", facts, body, r"`([a-z_]+)`", 2)
|
|
for row, keys in zip(rows, _install.EVENT_KEYS.values()):
|
|
row[1] = ", ".join(f"`{key}`" for key in keys)
|
|
return rows
|
|
|
|
|
|
def pad_rows(body: str) -> list[list[str]]:
|
|
facts = [(route, f"`{route}`") for route in _install.TUI_PAD_ROUTES]
|
|
return _with_prose("pad-routes", facts, body, r"`([a-z_]+)`", 2)
|
|
|
|
|
|
def route_keys() -> str:
|
|
routes = _install.TUI_ROUTES
|
|
actions = list(next(iter(routes.values())).help)
|
|
unnamed = [action for action in actions if action not in ACTIONS]
|
|
if unnamed or any(list(route.help) != actions for route in routes.values()):
|
|
raise MissingProse(f"route-keys: TUI_ROUTES actions {unnamed or 'differ by route'} "
|
|
"need a name in ACTIONS")
|
|
heading = ["Action", *(ROUTE_HEADINGS.get(name, f"`{name}`") for name in routes)]
|
|
rows = [[ACTIONS[action], *(route.help[action] or "none" for route in routes.values())]
|
|
for action in actions]
|
|
return _table(heading, rows)
|
|
|
|
|
|
def render(page: str) -> str:
|
|
"""The page with each marked table written from install.py."""
|
|
writers = {
|
|
"options": lambda body: _table(["Option", "Effect"], option_rows(body)),
|
|
"exit-codes": lambda body: _table(["Code", "Meaning"], exit_rows(body)),
|
|
"events": lambda body: _table(["Kind", "Keys", "When"], event_rows(body)),
|
|
"pad-routes": lambda body: _table(["Value", "Input", "Set by"], pad_rows(body)),
|
|
"route-keys": lambda _body: route_keys(),
|
|
}
|
|
seen = []
|
|
|
|
def write(match: re.Match[str]) -> str:
|
|
name = match.group("name")
|
|
if name not in writers:
|
|
raise MissingProse(f"no table is written for the marker {name!r}")
|
|
seen.append(name)
|
|
return f"{match.group('open')}\n{writers[name](match.group('body'))}\n{match.group('close')}"
|
|
|
|
text = BLOCK.sub(write, page)
|
|
absent = sorted(set(writers) - set(seen))
|
|
if absent:
|
|
raise MissingProse(f"markers missing from the page: {', '.join(absent)}")
|
|
return text
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--check", action="store_true",
|
|
help="exit 1 when a table differs from install.py, writing nothing")
|
|
parser.add_argument("--page", type=Path, default=PAGE, help=argparse.SUPPRESS)
|
|
args = parser.parse_args(argv)
|
|
current = args.page.read_text(encoding="utf-8")
|
|
try:
|
|
text = render(current)
|
|
except MissingProse as exc:
|
|
print(f"{args.page}: {exc}. Add the row inside its markers, then run "
|
|
"python scripts/generate_installer_doc.py", file=sys.stderr)
|
|
return 1
|
|
if text == current:
|
|
return 0
|
|
if args.check:
|
|
print(f"{args.page} differs from install.py: run "
|
|
"python scripts/generate_installer_doc.py", file=sys.stderr)
|
|
return 1
|
|
args.page.write_text(text, encoding="utf-8")
|
|
print(f"Wrote {args.page}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from common import run_cli
|
|
|
|
run_cli(main)
|