Files
libretro/scripts/generate_installer_doc.py
T
Abdessamad Derraz a4faf536dd fix: a games part is its system's card
Closes every finding of code_audit_23 and tui_audit_16: AmberELEC and
EmuELEC cards found as theirs, ArkOS cards told by bgmusic or
launchimages, counts that agree and read a copy of the right size before
calling it in place, one terminal width, line mode in the screen's order
and words, a tap shown armed, and which-pack naming the first screen.
2026-10-28 16:34:55 +01:00

306 lines
13 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, PurePosixPath
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 _marker(rule: _install._OsRule) -> str:
"""A card rule's evidence in words: the file or folder, and the line it
holds for a rule that reads one."""
if rule.method == "file_matches":
path, pattern = rule.values
line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "")
return f"`{path}` holding the line `{line}`"
named = ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`"
for value in rule.values)
if rule.method == "games_part":
# Told on the games part beside its bios folder, on a drive the
# system does not call fixed (install.games_part).
return f"{named} beside `bios/`, on a drive that is not a fixed disk"
return named
def _list_words(plat: str) -> str:
name = _install.display_name(plat)
return f"{name}'{'' if name.endswith('s') else 's'} list"
def card_marker_rows() -> list[list[str]]:
"""The files that name a card or a SHARE drive, in the order they are
read: install.py's card rules, then its SHARE drive files."""
rows = [[_marker(rule), rule.name,
f"`{rule.bios or 'bios'}`, {_list_words(rule.manifest)}"]
for rule in _install._card_rules()]
hostname = "system.hostname=BATOCERA"
for plat, marker in _install._SHARE_DRIVES:
name = _install.display_name(plat)
evidence = (f"`{marker}` holding `{hostname}` or no hostname line, and no KNULLI file "
"above" if plat == "batocera" else f"`{marker}`")
rows.append([evidence, f"{name} SHARE drive", f"`bios`, {_list_words(plat)}"])
rows.append([f"`{_install._SHARE_DRIVES[0][1]}` with any other hostname, and no KNULLI "
"file above, or the files of two SHARE drives",
"named by neither: the run says which systems it could be",
"the folder of the system named with `--platform`"])
return rows
# The folder the line menu example names: a home that is no one's.
MENU_FOLDER = "/home/me/.config/retroarch/system"
def line_menu() -> str:
"""The line menu of a RetroArch run, g then Enter pressed, as install.py
prints it for the RetroArch list (install/retroarch.json): the very
function line mode runs, its questions answered here. The page carried
a copy typed by hand, whose keys and counts drifted from the installer
(code_audit_22, owner's note on which-pack)."""
import contextlib # noqa: PLC0415
import io # noqa: PLC0415
import json # noqa: PLC0415
import os # noqa: PLC0415
from unittest import mock # noqa: PLC0415
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
files = [*manifest["files"], *(manifest.get("case_variants") or [])]
folder = Path(MENU_FOLDER)
out = io.StringIO()
answers = iter(["g", ""])
def answer(prompt: str) -> str:
given = next(answers)
print(f"{prompt}{given}".rstrip())
return given
invocation = _install.Invocation()
invocation.os_type = "linux"
environment = {key: value for key, value in os.environ.items()
if key not in (_install.OFFLINE_VARIABLE, "RETROBIOS_BOOTSTRAP")}
with mock.patch.dict(os.environ, environment, clear=True), \
mock.patch.object(_install, "_INVOCATION", invocation), \
mock.patch.object(_install, "_ask", answer), \
mock.patch.object(_install, "folder_warnings", return_value=[]), \
contextlib.redirect_stdout(out):
cut = [False]
def command(choice: object) -> str:
invocation.chosen["retroarch"] = (
(_install.chosen_flags(choice) if choice is not None else [])
+ ([("--no-game-data", None)] if cut[0] else []))
return invocation.command("retroarch", folder)
_install._prompt_custom_selection(
"retroarch", files, manifest.get("omitted_files") or [], None, "RetroArch",
False, manifest.get("data_directories") or [], folder,
_install.records_selection(files), None, manifest, False, command, cut)
return "```\n" + out.getvalue().strip("\n") + "\n```"
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(),
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
card_marker_rows()),
"line-menu": lambda _body: line_menu(),
}
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)
table = writers[name](match.group("body"))
return f"{match.group('open')}\n{table}\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)