feat: write installer tables from install.py

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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 09:47:44 +01:00
1 parent fb15ca68bb
commit 47ed945b4a
12 files changed
+1007 -196

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9"
$defaultInstallSha256 = "af5087172bc1909ad70f81c0e10c944db0d2326a3c9c1b4965c53faf1944d37c"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+278 -163
View File
@@ -341,7 +341,9 @@ def _enable_ansi() -> bool:
class Terminal:
"""What the output stream can show: a live line, colour, or neither.
A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb are honoured.
A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb or unset are
honoured, as is a terminal whose description moves no cursor the ANSI way
(vt52).
As no-color.org reads it, an empty NO_COLOR is not set: `NO_COLOR= cmd`
turned colour off. FORCE_COLOR or CLICOLOR_FORCE paints a pipe, for a
log viewer that shows colour, and never makes the line live.
@@ -351,7 +353,12 @@ class Terminal:
self.stream = stream or sys.stdout
self.quiet = quiet
isatty = getattr(self.stream, "isatty", None)
self.live = bool(isatty and isatty()) and os.environ.get("TERM") != "dumb"
# An unset TERM is a dumb one, as the full screen reads it. A Windows
# console sets none and draws the sequences once _enable_ansi is on.
term = os.environ.get("TERM", "")
self.live = (bool(isatty and isatty()) and term != "dumb"
and (bool(term) or os.name == "nt")
and not terminal_without_ansi(os.environ))
forced = any(os.environ.get(name, "0") not in ("", "0")
for name in ("FORCE_COLOR", "CLICOLOR_FORCE"))
self.colour = (
@@ -9048,9 +9055,9 @@ def _help_epilog() -> str:
return "\n".join(lines)
def main() -> None:
"""Entry point."""
_INVOCATION.os_type = detect_os()
def build_parser() -> argparse.ArgumentParser:
"""The options, in the order --help and wiki/installer.md list them
(scripts/generate_installer_doc.py writes its table from this parser)."""
parser = argparse.ArgumentParser(
allow_abbrev=False,
description="Download missing BIOS files for retrogaming emulators. "
@@ -9061,10 +9068,12 @@ def main() -> None:
)
parser.add_argument(
"--platform",
metavar="NAME",
help="target platform (retroarch, batocera, emudeck, ...)",
)
parser.add_argument(
"--dest",
metavar="PATH",
type=Path,
help="BIOS folder to install into. Without --platform, the file list is "
"the platform detected here, asked at a terminal, RetroArch's when "
@@ -9072,6 +9081,7 @@ def main() -> None:
)
parser.add_argument(
"--target",
metavar="NAME",
help="hardware target for core filtering (switch, rpi4, ...); "
"on Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system",
)
@@ -9080,57 +9090,19 @@ def main() -> None:
action="store_true",
help="install the files of every board, not only the detected one",
)
parser.add_argument(
"--check",
action="store_true",
help="check existing files without downloading",
)
parser.add_argument(
"--list-platforms",
action="store_true",
help="list detected platforms and exit",
)
parser.add_argument(
"--list-targets",
action="store_true",
help="list available targets for a platform and exit",
)
parser.add_argument(
"--jobs", "-j",
type=int,
default=8,
help="parallel download threads (default: 8)",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="verbose output",
)
parser.add_argument(
"--standalone-copies",
action="store_true",
help="opt in to copies into detected standalone-emulator directories",
)
parser.add_argument(
"--add-app",
action="store_true",
help="after the install, put the RetroBIOS app on the handheld card the BIOS "
"folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS "
"files from the console later",
)
parser.add_argument(
"--emulator",
action="append",
metavar="NAME",
help="install what these emulators read on the platform, as a pack "
"built for them carries (names: --list-emulators)",
)
parser.add_argument(
"--installed-cores",
action="store_true",
help="install only what the cores already installed read "
"(RetroArch, Lakka, RetroPie)",
)
parser.add_argument(
"--cores-dir",
type=Path,
metavar="PATH",
help="the folder RetroArch loads its cores from, when it is not found "
"(implies --installed-cores)",
)
parser.add_argument(
"--save-dir",
type=Path,
@@ -9139,26 +9111,26 @@ def main() -> None:
"for the files some cores read there (Azahar's keys and system "
"data), when it is not found",
)
parser.add_argument(
"--cores-dir",
type=Path,
metavar="PATH",
help="the folder RetroArch loads its cores from, when it is not found "
"(implies --installed-cores)",
)
parser.add_argument(
"--system",
action="append",
metavar="NAME",
metavar="NAMES",
help="install only these systems (repeat or comma-separate, see "
"--list-systems)",
)
parser.add_argument(
"--core",
action="append",
metavar="NAME",
metavar="NAMES",
help="keep only the extra files these cores load (see --list-cores)",
)
parser.add_argument(
"--emulator",
action="append",
metavar="NAMES",
help="install what these emulators read on the platform, as a pack "
"built for them carries (names: --list-emulators)",
)
parser.add_argument(
"--region",
action="append",
@@ -9188,6 +9160,11 @@ def main() -> None:
action="store_true",
help="list the cores whose extra files a platform installs and exit",
)
parser.add_argument(
"--list-emulators",
action="store_true",
help="list every name --emulator accepts for a platform and exit",
)
parser.add_argument(
"--romm-firmware",
metavar="TEMPLATE",
@@ -9195,16 +9172,16 @@ def main() -> None:
"filesystem.structure.firmware in its config.yml (default: read "
"from config.yml, else bios/{platform})",
)
parser.add_argument(
"--list-emulators",
action="store_true",
help="list every name --emulator accepts for a platform and exit",
)
parser.add_argument(
"--no-input",
action="store_true",
help="never ask a question and fail where an answer is needed",
)
parser.add_argument(
"--check",
action="store_true",
help="check existing files without downloading",
)
parser.add_argument(
"--dry-run",
action="store_true",
@@ -9227,11 +9204,13 @@ def main() -> None:
"usual text on stderr (implies --no-input)",
)
parser.add_argument(
"--restore",
"--from",
action="append",
type=Path,
metavar="FOLDER",
help=f"put back the files an earlier run replaced and kept in FOLDER "
f"(a dated folder inside {BACKUP_DIR})",
help="copy the BIOS files found in FOLDER (an old SD card, a backup, a "
"ZIP) instead of downloading them: every file recognised by its "
"hash, never moved. Repeat for several folders",
)
parser.add_argument(
"--from-pack",
@@ -9249,15 +9228,6 @@ def main() -> None:
"work without one, and a file the run would download fails at once "
f"({OFFLINE_VARIABLE}=1 does the same)",
)
parser.add_argument(
"--from",
action="append",
type=Path,
metavar="FOLDER",
help="copy the BIOS files found in FOLDER (an old SD card, a backup, a "
"ZIP) instead of downloading them: every file recognised by its "
"hash, never moved. Repeat for several folders",
)
parser.add_argument(
"--manifest",
type=Path,
@@ -9265,6 +9235,13 @@ def main() -> None:
help="read the file list from FILE (install/<platform>.json) instead "
"of downloading it",
)
parser.add_argument(
"--restore",
type=Path,
metavar="FOLDER",
help=f"put back the files an earlier run replaced and kept in FOLDER "
f"(a dated folder inside {BACKUP_DIR})",
)
parser.add_argument(
"--tui",
action="store_true",
@@ -9285,7 +9262,47 @@ def main() -> None:
version=f"retrobios installer {installer_id()} (ref {RELEASE_REF})",
help="print the installer version and exit",
)
parser.add_argument(
"--list-platforms",
action="store_true",
help="list detected platforms and exit",
)
parser.add_argument(
"--list-targets",
action="store_true",
help="list available targets for a platform and exit",
)
parser.add_argument(
"--jobs", "-j",
metavar="N",
type=int,
default=8,
help="parallel download threads (default: 8)",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="verbose output",
)
parser.add_argument(
"--standalone-copies",
action="store_true",
help="opt in to copies into detected standalone-emulator directories",
)
parser.add_argument(
"--add-app",
action="store_true",
help="after the install, put the RetroBIOS app on the handheld card the BIOS "
"folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS "
"files from the console later",
)
return parser
def main() -> None:
"""Entry point."""
_INVOCATION.os_type = detect_os()
parser = build_parser()
args = parser.parse_args()
if args.verbose:
_RAW_ERRORS.set()
@@ -11113,7 +11130,7 @@ def _plain_key(char: str) -> TuiKey | None:
"""The key one character stands for, None for a control nobody bound."""
named = {"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x08": "backspace",
"\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d",
"\x15": "ctrl-u"}.get(char)
"\x15": "ctrl-u", "\x1a": "ctrl-z"}.get(char)
if named:
return TuiKey(named)
if char < " " or unicodedata.category(char) in ("Cc", "Cs"):
@@ -11536,7 +11553,92 @@ def plain_requested(args: argparse.Namespace, env: Mapping[str, str] | None = No
# (ports/shared/tui.txt exports RB_PAD as this), so the bar names those
# buttons and no others.
TUI_PAD_ENV = "RETROBIOS_PAD"
TUI_PAD_ROUTES = ("gptokeyb", "gptokeyb_muxterm", "muxterm", "evmapy", "miyoo")
class TuiRoute(NamedTuple):
"""How one input route names the keys, read by the screen and by
scripts/generate_installer_doc.py for the key table of the page.
bar maps a key of the bottom line, written in the "both" form ("A/Enter
Install"), to the route's own name, None for a key the route lacks: a PC
user read "A/Enter", a muOS player was told a Start+Select that muxterm
does not have. help names each action of the help screen, the buttons
first, then the keys a keyboard plugged in also has, None for an action
the route lacks. stop is what stops an install the second time, then the
first time.
"""
bar: Mapping[str, str | None]
help: Mapping[str, str | None]
stop: tuple[str, str]
# retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page, Start
# Enter, Select Esc, Start+Select the kill.
_GPTOKEYB = TuiRoute(
{"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, "/": None,
"Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B", "Ctrl-U": None},
{"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "X, Space, Right/Left", "options": "Y, Tab, o", "back": "B, Select, Esc",
"stop": "B, Esc, Ctrl-C", "kill": "Select, then Start", "enter": "A, Enter",
"esc": "B, Esc"},
("B", "B or Start+Select"))
# keys is a keyboard's, and both names the button and the key where nothing
# says which one is held. The others are the values of RETROBIOS_PAD.
TUI_ROUTES: dict[str, TuiRoute] = {
"keys": TuiRoute(
{"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"},
{"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter",
"tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace",
"stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"},
("Esc or Ctrl-C", "Esc or Ctrl-C")),
"both": TuiRoute(
{},
{"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start",
"tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace",
"stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter", "esc": "Esc"},
("B or Ctrl-C", "B, Ctrl-C or Start+Select")),
"gptokeyb": _GPTOKEYB,
# gptokeyb2 with retrobios.gptk inside muxterm, which no longer reads the
# pad: the gptokeyb buttons. muxterm keeps PgUp and PgDn for its
# scrollback, and the launcher sends nothing on L1 and R1 there: Left and
# Right page.
"gptokeyb_muxterm": _GPTOKEYB._replace(help={**_GPTOKEYB.help,
"page": "D-pad Left/Right",
"tick": "X, Space"}),
# muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the
# terminal. A, X, L1 and R1 send nothing, Select shows its keyboard, and
# the D-pad's Left and Right page a list.
"muxterm": TuiRoute(
{"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None,
"/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None, "Ctrl-U": None},
{"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter",
"tick": "Y, Space", "options": "o, Tab", "back": "B, Esc, Backspace",
"stop": "B, Esc, Ctrl-C", "kill": None, "enter": "Start, Enter", "esc": "Esc"},
("B", "B")),
# RetroBIOS.sh.keys: the gptokeyb buttons, and no combo that ends the run.
"evmapy": TuiRoute(
{"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, "/": None,
"Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None},
{"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "X, Space, Right/Left", "options": "Y, Tab, o",
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "A, Enter", "esc": "B, Esc"},
("B", "B")),
# Buttons that are keys already: A Space, Start Enter, L2 Tab, R2
# Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a
# terminal (the console's keyboard driver only records its state), so
# the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl).
"miyoo": TuiRoute(
{"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None, "/": None,
"Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None},
{"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "A, Space, Right/Left", "options": "L2, Tab, o",
"back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
("R2", "R2")),
}
TUI_PAD_ROUTES = tuple(name for name in TUI_ROUTES if name not in ("keys", "both"))
# Set by Steam for what it starts, and by gamescope: a controller may be
# all there is, mapped by Steam Input to keys nobody here can name.
_STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY")
@@ -11588,6 +11690,81 @@ def _terminal_size(stream: TextIO) -> tuple[int, int]:
return size.columns, size.lines
# Where ncurses finds a terminal's compiled description after $TERMINFO,
# ~/.terminfo and $TERMINFO_DIRS.
_TERMINFO_DIRS = ("/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo",
"/usr/lib/terminfo", "/usr/share/lib/terminfo")
# cursor_address, string capability 10 of term(5).
_TERMINFO_CUP = 10
def _terminfo_folders(env: Mapping[str, str]) -> list[Path]:
folders = [Path(env["TERMINFO"])] if env.get("TERMINFO") else []
if env.get("HOME"):
folders.append(Path(env["HOME"]) / ".terminfo")
# An empty entry stands for the system's folders, which come last anyway.
folders += [Path(part) for part in env.get("TERMINFO_DIRS", "").split(":") if part]
return folders + [Path(folder) for folder in _TERMINFO_DIRS]
def terminfo_string(data: bytes, index: int) -> str | None:
"""String capability index of a compiled term(5) entry, "" when the
entry lacks it, None when data is no compiled entry."""
def short(at: int) -> int:
return int.from_bytes(data[at:at + 2], "little", signed=True)
if len(data) < 12:
return None
# The legacy format stores numbers in 2 bytes, the extended one in 4.
width = {0o432: 2, 0o1036: 4}.get(short(0))
if width is None:
return None
names, bools, numbers, strings = short(2), short(4), short(6), short(8)
offsets = 12 + names + bools
offsets += offsets % 2 + numbers * width
if index >= strings:
return ""
at = short(offsets + 2 * index)
if at < 0:
return ""
start = offsets + 2 * strings + at
end = data.find(b"\0", start)
if end < 0:
return None
return data[start:end].decode("latin-1")
def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None:
"""How TERM's description here moves the cursor, "" when it cannot,
None when no description is found: Windows, Android and many handheld
images carry none, and the screen is not held back for it."""
if not term or "/" in term or term.startswith("."):
return None
for folder in _terminfo_folders(env):
# Linux files xterm under x/, macOS under 78/.
for sub in (term[0], f"{ord(term[0]):02x}"):
try:
data = (folder / sub / term).read_bytes()
except (OSError, ValueError):
continue
return terminfo_string(data, _TERMINFO_CUP)
return None
def terminal_without_ansi(env: Mapping[str, str]) -> str:
"""Why TERM's description here says the ANSI sequences the screen and
the progress line write would print as text, "" when it does not."""
if os.name == "nt":
return ""
term = env.get("TERM", "")
cup = terminal_cursor_address(term, env)
if cup == "":
return f"TERM={term} cannot move the cursor"
if cup is not None and not cup.startswith(("\x1b[", "\x9b")):
return f"TERM={term} does not take the escape sequences the screen is drawn with"
return ""
def tui_blocker(
args: argparse.Namespace, stdin: TextIO | None = None, stdout: TextIO | None = None,
env: Mapping[str, str] | None = None,
@@ -11612,6 +11789,9 @@ def tui_blocker(
return f"the {name} is not a terminal"
if os.name != "nt" and env.get("TERM", "") in ("", "dumb"):
return "TERM is dumb or not set"
drawing = terminal_without_ansi(env)
if drawing:
return drawing
# A pseudo-terminal nobody sized reports 0x0: script(1) fed from a pipe
# is one, and the line questions run there as they always have.
cols, rows = _terminal_size(stdout)
@@ -13371,42 +13551,12 @@ _SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o "
("Left/Right ", "<> "), ("Up/Down ", "^v "))
# The key of a hint as each route names it, None for a key it lacks. The
# bar is written in the "both" form ("A/Enter Install"), the route's own
# names replace it: a PC user read "A/Enter", a muOS player was told a
# Start+Select that muxterm does not have.
_ROUTE_KEYS: dict[str, dict[str, str | None]] = {
"keys": {"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"},
# retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page,
# Start Enter, Select Esc, Start+Select the kill.
"gptokeyb": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
"/": None, "Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B",
"Ctrl-U": None},
# RetroBIOS.sh.keys: the same buttons, and no combo that ends the run.
"evmapy": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
"/": None, "Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None},
# muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the
# terminal. A, X, L1 and R1 send nothing, Select shows its keyboard.
"muxterm": {"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None,
"/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None,
"Ctrl-U": None},
# Buttons that are keys already: A Space, Start Enter, L2 Tab, R2
# Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a
# terminal (the console's keyboard driver only records its state), so
# the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl).
"miyoo": {"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None,
"/": None, "Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None},
}
# gptokeyb2 with retrobios.gptk inside muxterm, which no longer reads the pad:
# the gptokeyb buttons.
_ROUTE_KEYS["gptokeyb_muxterm"] = _ROUTE_KEYS["gptokeyb"]
def _route_hint(state: TuiState, parts: tuple) -> tuple:
"""The bar's entries named as the run's input names them."""
names = _ROUTE_KEYS.get(state.pad)
if names is None or state.help:
route = TUI_ROUTES.get(state.pad)
if route is None or state.help:
return parts
names = route.bar
named = []
for part in parts:
key, sep, what = part.partition(" ")
@@ -14218,18 +14368,6 @@ def _bar(share: float, width: int) -> str:
return f"[{'#' * filled}{'-' * (room - filled)}] {int(share * 100):3d}%"
# What stops an install a second time, and the first time, per route.
_STOP_KEYS = {
"both": ("B or Ctrl-C", "B, Ctrl-C or Start+Select"),
"keys": ("Esc or Ctrl-C", "Esc or Ctrl-C"),
"gptokeyb": ("B", "B or Start+Select"),
"gptokeyb_muxterm": ("B", "B or Start+Select"),
"evmapy": ("B", "B"),
"muxterm": ("B", "B"),
"miyoo": ("R2", "R2"),
}
def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines: list = []
if state.download is None:
@@ -14252,7 +14390,7 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}")
if files_done >= state.download["files"] and state.summary is None:
lines.append("Finishing...")
stops = _STOP_KEYS.get(state.pad, _STOP_KEYS["both"])
stops = TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).stop
lines.append(_styled(f"Stopping, finishing the files in flight. Press {stops[0]} "
"again to stop now." if state.stopping
else f"{stops[1]} stops. What is done stays done.", "dim"))
@@ -14526,41 +14664,6 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = {
}
# The help's names for each action, per route: the buttons first, then the
# keys a keyboard plugged in also has. None for an action the route lacks.
_HELP_KEYS: dict[str, dict[str, str | None]] = {
"both": {"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start",
"tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace",
"stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter",
"esc": "Esc"},
"keys": {"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter",
"tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace",
"stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"},
"gptokeyb": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn",
"ok": "A, Start, Enter", "tick": "X, Space, Right/Left",
"options": "Y, Tab, o", "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C",
"kill": "Select, then Start", "enter": "A, Enter", "esc": "B, Esc"},
"evmapy": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "X, Space, Right/Left", "options": "Y, Tab, o",
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "A, Enter", "esc": "B, Esc"},
# muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1
# send nothing: the D-pad's Left and Right page a list there.
"muxterm": {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter",
"tick": "Y, Space", "options": "o, Tab",
"back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
"miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
"tick": "A, Space, Right/Left", "options": "L2, Tab, o",
"back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
}
# muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the
# pad, and the launcher sends nothing on L1 and R1 there: Left and Right page.
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": "D-pad Left/Right",
"tick": "X, Space"}
def _route_extras(state: TuiState) -> list[tuple[str, str]]:
"""What a route's own buttons do that no key of the table says."""
if state.pad == "muxterm":
@@ -14572,7 +14675,7 @@ def _route_extras(state: TuiState) -> list[tuple[str, str]]:
def _help_lines(state: TuiState) -> list[tuple[str, str]]:
screen = state.screen
names = _HELP_KEYS.get(state.pad, _HELP_KEYS["both"])
names = TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).help
# gptokeyb's kill: Start pressed first also chooses the row in focus.
kill = ([(names["kill"], "Quit at once")]
if state.pad in ("gptokeyb", "gptokeyb_muxterm") else [])
@@ -14666,10 +14769,15 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple:
def tui_view(state: TuiState, cols: int, rows: int) -> list[str]:
"""The screen as rows lines of at most cols cells, from the state alone."""
if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS:
# How large, and how large it is now: "larger" alone left the
# reader resizing by guess.
lines = [_styled("")] * rows
text = fit("Make the window larger", cols)
if rows:
lines[rows // 2] = _styled(" " * max(0, (cols - cell_width(text)) // 2) + text)
said = [fit(text, cols) for text in (
"Make the window larger:",
f"{TUI_MIN_COLS}x{TUI_MIN_ROWS} or more, now {cols}x{rows}")][:rows]
top = max(0, (rows - len(said)) // 2)
for index, text in enumerate(said):
lines[top + index] = _styled(" " * max(0, (cols - cell_width(text)) // 2) + text)
return lines
width = min(cols, TUI_MAX_WIDTH) - 2
height = rows - 3
@@ -15364,6 +15472,13 @@ def run_tui(args: argparse.Namespace) -> int:
state.now = time.monotonic()
terminal.draw(tui_view(state, cols, rows), cols)
for key in terminal.keys(TUI_FRAME):
if key.name == "ctrl-z":
# ISIG is off, so Ctrl-Z comes as a key: it stops the run
# as kill -TSTP does, where the system has job control.
if hasattr(signal, "SIGSTOP"):
flags["suspend"] = True
break
continue
pressed = True
state.now = time.monotonic()
perform(tui_update(state, key))
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9"
DEFAULT_INSTALL_SHA256="af5087172bc1909ad70f81c0e10c944db0d2326a3c9c1b4965c53faf1944d37c"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 692187,
"sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9",
"size": 696739,
"sha256": "af5087172bc1909ad70f81c0e10c944db0d2326a3c9c1b4965c53faf1944d37c",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 744736,
"sha256": "17daef144acc01204912de201bbb6bbf8996d1a866a37074e7cbaffe879e911b"
"size": 749288,
"sha256": "b1316b8db9411f83201d626ae4f27a784f2aa338bfc934e8d219d91570d8c027"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 692187,
"sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9",
"size": 696739,
"sha256": "af5087172bc1909ad70f81c0e10c944db0d2326a3c9c1b4965c53faf1944d37c",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 692187,
"sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9",
"size": 696739,
"sha256": "af5087172bc1909ad70f81c0e10c944db0d2326a3c9c1b4965c53faf1944d37c",
"executable": false
},
{
+210
View File
@@ -0,0 +1,210 @@
#!/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)
+9 -2
View File
@@ -820,9 +820,10 @@ def installer_options() -> tuple[tuple[str, str], ...]:
if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
}
main = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "main")
builder = next(node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "build_parser")
options = []
for node in ast.walk(main):
for node in ast.walk(builder):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_argument"):
continue
@@ -957,9 +958,15 @@ def hide_hashes(markdown: str) -> str:
return "".join(pieces)
# The markers scripts/generate_installer_doc.py writes its tables between:
# for the source only, the page shows the table alone.
GENERATED_MARKER = re.compile(r"(?m)^<!-- /?generated: [a-z-]+ -->\n\n?")
def prepare_page(markdown: str, page: Path) -> str:
"""Everything this module does to a page before it is decorated."""
markdown = expand_glance(expand_options(expand_commands(resolve_images(localize_images(markdown, page)))))
markdown = GENERATED_MARKER.sub("", markdown)
if page.parts[:1] == ("wiki",):
markdown = hide_hashes(markdown)
return markdown
+2 -2
View File
@@ -688,9 +688,9 @@ class FirstInstallWalkthrough(unittest.TestCase):
import install
section = _section(self.text, "4. Wait")
computer = install._STOP_KEYS["keys"][0].replace("Ctrl-C", "Ctrl+C")
computer = install.TUI_ROUTES["keys"].stop[0].replace("Ctrl-C", "Ctrl+C")
self.assertIn(f"{computer} stops", section)
self.assertIn(f"{install._STOP_KEYS['gptokeyb'][0]} on a handheld", section)
self.assertIn(f"{install.TUI_ROUTES['gptokeyb'].stop[0]} on a handheld", section)
def test_no_hand_kept_size_before_the_run(self):
import install
+91
View File
@@ -5334,6 +5334,97 @@ class TheEventSchemaFileIsEventKeys(unittest.TestCase):
self.assertEqual(generator.main(["--check", "--output", str(scratch)]), 0)
class TheInstallerPageIsWrittenFromTheCode(unittest.TestCase):
"""The options, events, exit codes and keys of wiki/installer.md were
written by hand beside the code that defines them, and every audit
found one of them behind it."""
PAGE = REPO_ROOT / "wiki" / "installer.md"
def setUp(self):
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_installer_doc # noqa: PLC0415
self.generator = generate_installer_doc
self.scratch = Path(_tempdir(self)) / "installer.md"
self.scratch.write_text(self.PAGE.read_text(encoding="utf-8"), encoding="utf-8")
def run_generator(self, *argv: str) -> tuple[int, str]:
err = io.StringIO()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err):
code = self.generator.main([*argv, "--page", str(self.scratch)])
return code, err.getvalue()
def test_the_committed_page_is_the_generated_one(self):
with contextlib.redirect_stderr(io.StringIO()) as err:
self.assertEqual(self.generator.main(["--check"]), 0,
err.getvalue() + "run python scripts/generate_installer_doc.py")
def test_a_hand_edit_of_a_fact_fails_the_check(self):
page = self.scratch.read_text(encoding="utf-8")
for old, new in (("`--jobs N`", "`--jobs JOBS`"),
("| `end` | `code` |", "| `end` | `code`, `reason` |"),
("| Quit at once | none |", "| Quit at once | q |"),
# A code install.py never exits with.
("| `141` |", "| `142` | stopped twice |\n| `141` |")):
with self.subTest(edit=new):
self.assertIn(old, page)
self.scratch.write_text(page.replace(old, new, 1), encoding="utf-8")
self.assertEqual(self.run_generator("--check")[0], 1)
self.assertEqual(self.run_generator()[0], 0)
self.assertEqual(self.scratch.read_text(encoding="utf-8"), page)
def test_the_prose_is_kept(self):
page = self.scratch.read_text(encoding="utf-8")
old = "| Parallel downloads, 1 to 32, default 8 |"
self.assertIn(old, page)
edited = page.replace(old, "| Parallel downloads, from 1 to 32 |")
self.scratch.write_text(edited, encoding="utf-8")
self.assertEqual(self.run_generator("--check")[0], 0)
def test_what_the_code_gains_stops_it_until_the_page_says_it(self):
real = self.generator._install.build_parser
def gained() -> argparse.ArgumentParser:
parser = real()
parser.add_argument("--brand-new", action="store_true", help="new")
return parser
page = self.scratch.read_text(encoding="utf-8")
events = {**self.generator._install.EVENT_KEYS, "brand_new": ("thing",)}
for name, patch in (
("--brand-new", unittest.mock.patch.object(
self.generator._install, "build_parser", gained)),
("brand_new", unittest.mock.patch.object(
self.generator._install, "EVENT_KEYS", events))):
with self.subTest(gained=name), patch:
code, said = self.run_generator()
self.assertEqual(code, 1)
self.assertIn(name, said)
self.assertEqual(self.scratch.read_text(encoding="utf-8"), page)
self.assertEqual(self.run_generator("--check")[0], 1)
# The row added by hand, its first cell alone: the generator fills it.
close = "\n\n<!-- /generated: options -->"
self.scratch.write_text(
page.replace(close, "\n| `--brand-new` | Something new |" + close, 1),
encoding="utf-8")
with unittest.mock.patch.object(self.generator._install, "build_parser", gained):
self.assertEqual(self.run_generator()[0], 0)
self.assertIn('| <span id="option-brand-new"></span>`--brand-new` | Something new |',
self.scratch.read_text(encoding="utf-8"))
def test_the_exit_codes_are_the_ones_install_py_exits_with(self):
self.assertEqual(self.generator.exit_codes(), [0, 1, 2, 130, 141])
def test_a_missing_marker_stops_it(self):
page = self.scratch.read_text(encoding="utf-8")
self.scratch.write_text(page.replace("<!-- generated: route-keys -->\n", ""),
encoding="utf-8")
code, said = self.run_generator("--check")
self.assertEqual(code, 1)
self.assertIn("route-keys", said)
class NetworkFailuresInPlainWords(unittest.TestCase):
"""Offline, the run printed "[Errno -2] Name or service not known". A
beginner needs "no internet connection", and the raw text only under
+2 -1
View File
@@ -878,7 +878,8 @@ class ColourFollowsNoColorOrg(unittest.TestCase):
def _colour(self, **env: str) -> bool:
clean = {k: v for k, v in os.environ.items()
if k not in ("NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE", "TERM")}
with mock.patch.dict(os.environ, {**clean, **env}, clear=True), \
# A terminal that is named: an unset TERM is a dumb one.
with mock.patch.dict(os.environ, {**clean, "TERM": "xterm", **env}, clear=True), \
mock.patch.object(install, "_enable_ansi", return_value=True):
return install.Terminal(_Tty()).colour
+333 -8
View File
@@ -153,7 +153,9 @@ class KeyDecoding(unittest.TestCase):
[Key("paste", "/a/b"), Key("enter")])
def test_controls_nobody_bound_are_ignored(self):
self.assertEqual(self.feed("\x00\x1a\x1cx"), keys("char:x"))
self.assertEqual(self.feed("\x00\x1cx"), keys("char:x"))
# Ctrl-Z is a key once ISIG is off: the run stops on it, as on SIGTSTP.
self.assertEqual(self.feed("\x1a"), [Key("ctrl-z")])
def test_windows_scan_codes(self):
for prefix in ("\x00", "\xe0"):
@@ -738,7 +740,9 @@ class Flow(unittest.TestCase):
self.assertEqual(state.screen, "setup")
lines = install.tui_view(state, 30, 10)
self.assertEqual([line.strip() for line in lines if line.strip()],
["Make the window larger"])
["Make the window larger:", "40x12 or more, now 30x10"])
self.assertEqual(len(install.tui_view(state, 39, 1)), 1)
self.assertEqual(install.tui_view(state, 30, 0), [])
class PlanWarnings(unittest.TestCase):
@@ -4212,6 +4216,9 @@ class Pty:
if pid:
while self._pump(0.05) and select.select([self.fd], [], [], 0)[0]:
pass
# The modes the run left the terminal in: the master reads
# the slave's.
self.modes = termios.tcgetattr(self.fd)
os.close(self.fd)
self.status = os.WEXITSTATUS(status)
return self.status
@@ -4853,7 +4860,7 @@ class EndToEnd(unittest.TestCase):
screen = term.wait("> Another setup or folder")
self.assertIn("B/Esc Quit", screen.splitlines()[29])
term.resize(30, 10)
term.wait("Make the window larger")
term.wait("40x12 or more, now 30x10")
term.resize(40, 12)
term.wait("Where do you play?")
term.send("q")
@@ -4873,13 +4880,272 @@ class EndToEnd(unittest.TestCase):
self.env.pop(name)
def test_dumb_terminal_writes_no_escape(self):
"""TERM unset printed the banner bold and dim, and redrew the
progress with CSI, where the screen itself read it as dumb."""
self.repo()
self.env["TERM"] = "dumb"
for value in ("dumb", None):
with self.subTest(term=value):
if value is None:
self.env.pop("TERM", None)
else:
self.env["TERM"] = value
term = self.start()
term.wait("Enter) install everything")
term.send("\r")
term.wait("installed")
term.close()
self.assertNotIn(b"\x1b", term.raw)
def test_a_terminal_that_cannot_draw_the_screen_gets_the_questions(self):
"""TERM=vt52 moves the cursor with ESC Y: the screen opened and drew
CSI sequences it printed as text."""
self.repo()
self.env["TERMINFO"] = str(_terminfo_folder(self))
for name in ("rbtest-glass", "rbtest-vt52"):
with self.subTest(term=name):
self.env["TERM"] = name
term = self.start()
term.wait("Enter) install everything")
term.send("q\r")
term.close()
self.assertNotIn(b"\x1b[?1049h", term.raw)
term = self.start("--tui")
self.assertEqual(term.close(), 2)
said = term.raw.decode()
self.assertIn(f"--tui cannot open the full-screen interface here: TERM={name}",
said)
self.assertNotIn("\x1b", said)
def test_no_color_draws_no_colour(self):
"""The screen marks the row in focus with > as well as reverse video,
and draws no colour at all: NO_COLOR has nothing left to remove."""
self.repo()
self.env["NO_COLOR"] = "1"
term = self.start()
term.wait("Enter) install everything")
term.send("q\r")
term.close()
self.assertNotIn(b"\x1b", term.raw)
screen = term.wait("Where do you play?")
self.assertIn("> RetroArch", screen)
term.send("q")
self.assertEqual(term.close(), 130)
for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw):
codes = {int(code) for code in params.split(b";") if code}
self.assertFalse(codes & {*range(30, 50), *range(90, 108)}, params)
def _terminfo_entry(name: str, cup: str | None, extended: bool = False) -> bytes:
"""A compiled term(5) entry holding the strings up to cursor_address,
only that one set."""
def short(value: int) -> bytes:
return value.to_bytes(2, "little", signed=True)
names = name.encode() + b"|test terminal\0"
table = (cup.encode("latin-1") + b"\0") if cup is not None else b""
offsets = [-1] * (install._TERMINFO_CUP + 1)
if cup is not None:
offsets[install._TERMINFO_CUP] = 0
body = names + (b"\0" if len(names) % 2 else b"")
# One number, columns, in the width of the format.
body += (80).to_bytes(4 if extended else 2, "little")
body += b"".join(short(offset) for offset in offsets) + table
header = b"".join(short(value) for value in (
0o1036 if extended else 0o432, len(names), 0, 1, len(offsets), len(table)))
return header + body
def _terminfo_folder(test: unittest.TestCase) -> Path:
"""A $TERMINFO with a glass terminal, a VT52 and two ANSI terminals,
one in the extended format."""
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
folder = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
test.addCleanup(shutil.rmtree, folder, True)
for name, cup, extended in (
("rbtest-glass", None, False),
("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False),
("rbtest-ansi", "\x1b[%i%p1%d;%p2%dH", False),
("rbtest-ansi2", "\x1b[%i%p1%d;%p2%dH", True)):
(folder / "r").mkdir(exist_ok=True)
(folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended))
return folder
class TerminalsTheScreenCannotDraw(unittest.TestCase):
"""The screen writes CSI sequences: a terminal whose description moves
the cursor otherwise, or not at all, gets the line questions."""
def setUp(self):
self.master, self.slave = os.openpty()
self.addCleanup(os.close, self.master)
self.addCleanup(os.close, self.slave)
fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
self.tty = StartConditions._Tty(self.slave)
self.env = {"TERMINFO": str(_terminfo_folder(self))}
def blocker(self, term: str) -> str:
return install.tui_blocker(_args(), self.tty, self.tty, {**self.env, "TERM": term})
def test_the_cursor_address_is_read(self):
self.assertEqual(install.terminal_cursor_address("rbtest-ansi", self.env),
"\x1b[%i%p1%d;%p2%dH")
self.assertEqual(install.terminal_cursor_address("rbtest-ansi2", self.env),
"\x1b[%i%p1%d;%p2%dH")
self.assertEqual(install.terminal_cursor_address("rbtest-glass", self.env), "")
self.assertIsNone(install.terminal_cursor_address("rbtest-none", self.env))
def test_ansi_opens_and_the_others_say_why(self):
self.assertEqual(self.blocker("rbtest-ansi"), "")
self.assertEqual(self.blocker("rbtest-ansi2"), "")
self.assertEqual(self.blocker("rbtest-glass"),
"TERM=rbtest-glass cannot move the cursor")
self.assertIn("escape sequences", self.blocker("rbtest-vt52"))
def test_no_description_is_no_refusal(self):
"""Android and most handheld images carry no terminfo: the screen
draws there as before."""
self.assertEqual(self.blocker("rbtest-none"), "")
self.assertEqual(self.blocker("../r/rbtest-glass"), "")
def test_the_folders_are_searched_in_order(self):
home = Path(self.env["TERMINFO"])
(home / ".terminfo" / "r").mkdir(parents=True)
(home / ".terminfo" / "r" / "rbtest-ansi").write_bytes(
_terminfo_entry("rbtest-ansi", None))
# $TERMINFO first, then ~/.terminfo, then $TERMINFO_DIRS.
self.assertTrue(install.terminal_cursor_address(
"rbtest-ansi", {**self.env, "HOME": str(home)}))
self.assertEqual(install.terminal_cursor_address(
"rbtest-ansi", {"HOME": str(home), "TERMINFO_DIRS": self.env["TERMINFO"]}), "")
# macOS files a terminal under the hex of its first letter.
(home / "72").mkdir()
(home / "72" / "rbtest-hex").write_bytes(_terminfo_entry("rbtest-hex", None))
self.assertEqual(install.terminal_cursor_address(
"rbtest-hex", {"TERMINFO_DIRS": str(home)}), "")
def test_the_line_questions_draw_no_escape_there_either(self):
"""The progress line redraws with CSI and paints with SGR: on a VT52
both print as text, as on TERM=dumb."""
for term, live in (("rbtest-ansi", True), ("rbtest-vt52", False),
("rbtest-glass", False)):
with self.subTest(term=term), mock.patch.dict(
os.environ, {**self.env, "TERM": term}), \
mock.patch.object(install, "_enable_ansi", return_value=True):
os.environ.pop("NO_COLOR", None)
shown = install.Terminal(self.tty)
self.assertEqual((shown.live, shown.colour), (live, live))
self.assertEqual(shown.ok("ok") == "ok", not live)
def test_an_unset_term_is_a_dumb_one(self):
with mock.patch.dict(os.environ, {"FORCE_COLOR": ""}), \
mock.patch.object(install, "_enable_ansi", return_value=True):
for name in ("TERM", "NO_COLOR"):
os.environ.pop(name, None)
shown = install.Terminal(self.tty)
self.assertEqual((shown.live, shown.colour), (False, False))
with mock.patch.object(install.os, "name", "nt"):
self.assertTrue(install.Terminal(self.tty).live)
def test_a_damaged_description_is_no_description(self):
for data in (b"", b"\x1a\x01", b"\x00" * 12, _terminfo_entry("x", "\x1b[H")[:-1]):
with self.subTest(data=data):
self.assertIsNone(install.terminfo_string(data, install._TERMINFO_CUP))
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
class TheTerminalComesBack(unittest.TestCase):
"""However the screen ends or pauses, the terminal is handed back as it
was lent: the cursor shown, the main screen, echo, lines and signals."""
repo = EndToEnd.repo
start = EndToEnd.start
# What a new pseudo-terminal has, which the child started on.
@staticmethod
def fresh_modes() -> list:
master, slave = os.openpty()
try:
return termios.tcgetattr(slave)
finally:
os.close(master)
os.close(slave)
def assertGivenBack(self, term: Pty, since: int = 0) -> None:
raw = term.raw[since:]
self.assertGreater(raw.rfind(b"\x1b[?1049l"), raw.rfind(b"\x1b[?1049h"), raw[-200:])
self.assertGreater(raw.rfind(b"\x1b[?25h"), raw.rfind(b"\x1b[?25l"), raw[-200:])
def assertModesBack(self, modes: list) -> None:
fresh = self.fresh_modes()
for index, flag in ((3, termios.ECHO), (3, termios.ICANON), (3, termios.ISIG),
(3, termios.IEXTEN), (0, termios.ICRNL), (0, termios.IXON)):
self.assertEqual(modes[index] & flag, fresh[index] & flag, (index, flag))
self.assertEqual(modes, fresh)
def ended(self, how) -> Pty:
self.repo()
term = self.start()
term.wait("Where do you play?")
how(term)
return term
def test_ctrl_c(self):
term = self.ended(lambda term: term.send("\x03"))
self.assertEqual(term.close(), 130)
self.assertGivenBack(term)
self.assertModesBack(term.modes)
def test_eof_on_stdin(self):
"""Ctrl-D, the terminal's end of input, typed at the screen."""
term = self.ended(lambda term: term.send("\x04"))
self.assertEqual(term.close(), 130)
self.assertGivenBack(term)
self.assertModesBack(term.modes)
def test_sigterm(self):
import signal
term = self.ended(lambda term: os.kill(term.pid, signal.SIGTERM))
self.assertEqual(term.close(), 128 + signal.SIGTERM)
self.assertGivenBack(term)
self.assertModesBack(term.modes)
def test_sighup(self):
"""The hangup without the window gone (kill -HUP, an ssh session
dropped while its pty lingers)."""
import signal
term = self.ended(lambda term: os.kill(term.pid, signal.SIGHUP))
self.assertEqual(term.close(), 128 + signal.SIGHUP)
self.assertGivenBack(term)
self.assertModesBack(term.modes)
def test_ctrl_z_then_fg(self):
"""ISIG is off on the screen, so Ctrl-Z reached it as a byte and did
nothing: the shell never got its terminal back."""
import signal
term = self.ended(lambda term: None)
mark = len(term.raw)
term.send("\x1a")
end, stopped = time.monotonic() + 10, False
while time.monotonic() < end and not stopped:
term._pump(0.05)
pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
stopped = bool(pid) and os.WIFSTOPPED(status)
self.assertTrue(stopped, term.text())
self.assertGivenBack(term, mark)
self.assertModesBack(termios.tcgetattr(term.fd))
# fg: the shell's SIGCONT, and the screen is back, raw again.
mark = len(term.raw)
os.kill(term.pid, signal.SIGCONT)
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.wait("Where do you play?")
self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO)
term.send("q")
self.assertEqual(term.close(), 130)
self.assertGivenBack(term)
self.assertModesBack(term.modes)
class TheFolderScreenSaysWhatItOffers(unittest.TestCase):
@@ -6043,5 +6309,64 @@ class AuditSevenScreens(unittest.TestCase):
self.assertIn("1 wrong copy: backed up, replaced.", view)
class EdgesTheMutantsFound(unittest.TestCase):
"""Changes to the code that every test let through: the survivors of a
mutmut run over the device filter, the card bounds and the WSL paths."""
def test_a_folder_below_a_drive_is_named_with_backslashes(self):
"""Only the drive itself was ever shown: D:\\Games/bios passed."""
for root in ("/mnt", "/mnt/", "/windows", "/drvX/"):
with self.subTest(root=root), mock.patch.dict(install._WSL,
{"active": True, "root": root}):
base = root.rstrip("/")
self.assertEqual(install.shown_path(Path(f"{base}/d/Games/bios")),
"D:\\Games\\bios")
self.assertEqual(install.shown_path(Path(f"{base}/e")), "E:\\")
def test_every_path_option_is_read_through_drvfs(self):
"""--cores-dir G:\\ under WSL stayed a relative path named G:."""
with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}):
args = _args(dest=Path("D:\\bios"), save_dir=Path("F:\\saves"),
cores_dir=Path("G:\\RetroArch\\cores"))
install._wsl_dest(args)
self.assertEqual((args.dest, args.save_dir, args.cores_dir),
(Path("/mnt/d/bios"), Path("/mnt/f/saves"),
Path("/mnt/g/RetroArch/cores")))
def card(self, system: str) -> Path:
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
make_card(root / "card", system)
return root / "card"
def test_a_card_is_looked_for_three_folders_up_and_no_further(self):
card = self.card("KNULLI")
setup = install.card_setup("KNULLI", "knulli")
with mock.patch.object(install, "_mounted_cards", return_value=[]):
self.assertEqual(install._card_above(setup, card / "a" / "b" / "c"),
("own", "KNULLI", card))
self.assertIsNone(install._card_above(setup, card / "a" / "b" / "c" / "d"))
def test_a_minui_root_is_looked_for_two_folders_up_and_no_further(self):
card = self.card("MinUI")
self.assertEqual(install.card_root_above(card / "Bios" / "GBA"), ("minui", card))
self.assertIsNone(install.card_root_above(card / "Bios" / "GBA" / "x"))
self.assertIsNone(install.card_root_above(card))
def test_the_filter_compares_the_letters_alone(self):
self.assertEqual(install._folded("Pokémon Évolution"), "pokemon evolution")
self.assertFalse(install._matches("Odroid", "x"))
def test_a_letter_no_name_holds_finds_nothing(self):
with mock.patch.dict(install._SETUP_DEVICES, {"batocera": ("Odroid",),
"Onion": ("Miyoo Mini",)}):
self.assertIsNone(install._setup_matches("batocera", ["Onion"], "x"))
self.assertEqual(install._setup_matches("batocera", ["Onion"], "mini"),
["Onion"])
self.assertEqual(install._setup_matches("batocera", ["Onion"], "odroid"),
["Odroid"])
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -376,6 +376,8 @@ class InstallerOptionsAtAGlance(unittest.TestCase):
with self.subTest(option=flag):
self.assertIn(f"[`{flag}`](#option-{flag.lstrip('-')})", listed)
self.assertNotIn("<!-- installer-options", text)
# The markers of scripts/generate_installer_doc.py stay in the source.
self.assertNotIn("generated:", text)
if __name__ == "__main__":
+70 -10
View File
@@ -163,19 +163,23 @@ after a change of options, the folder is checked again, so its numbers are the
exact ones for the new choice. Ticked systems get their exact count before the
install starts.
| Action | Keyboard | Controller |
|--------|----------|------------|
| Action | Keyboard | Controller (gptokeyb, evmapy) |
|--------|----------|------------------------------|
| Move | Up and Down, `k` and `j` | D-pad Up and Down |
| Page | PgUp and PgDn | L1 and R1 |
| Page | PgUp and PgDn | L1 and R1, D-pad Right and Left inside muxterm |
| First, last | Home and End, `g` and `G` | |
| Continue, open, choose | Enter, and Space outside a list of ticks | A, Start |
| Tick, untick | Space, Right, Left | X, D-pad Right and Left |
| Back | Esc, Backspace | B, Select |
| Tick, untick | Space, Right, Left | X, D-pad Right and Left (X alone inside muxterm, Y with muxterm's own keys) |
| Back | Esc, Backspace | B, Select (B alone with muxterm's own keys, where Select shows its keyboard) |
| Options | `o`, Tab | Y |
| Filter a long list | `/` then type, Enter on a lone match chooses it | none: a pad cannot type (the setup list puts the SD cards after RetroArch) |
| Keys of the screen | `?`, F1 | |
| Stop an install | Esc, `q`, Ctrl+C | B, Start+Select |
| Stop an install | Esc, `q`, Ctrl+C | B, Start+Select (B alone with muxterm's own keys) |
| Quit | `q`, Ctrl+C | B on the first screen |
| Pause, back with `fg` (Linux, macOS) | Ctrl+Z | |
The buttons of each handheld route, muxterm's and a Miyoo's among them, are
in the [key table of each route](#on-a-handheld-the-retrobios-port).
The bottom line of each screen names what A does on the row under the cursor,
in the names of what is held. On a computer it names the keys (`Enter Install`,
@@ -193,10 +197,21 @@ once the screen closes, with the command that repeats the run. A run that failed
keeps its log and names it, and every other run leaves nothing behind.
It opens only for an install, at a terminal of at least 40 columns and 12 rows
that is not `TERM=dumb`. A listing, `--check`, `--dry-run`, `--events`,
(a smaller window says so, with its size) whose `TERM` is set, is not `dumb`
and can draw it: where the terminal's description
(terminfo) has no way to move the cursor, or moves it with other sequences
than the ANSI ones the screen writes (`TERM=vt52`), the questions come line by
line instead, by themselves. A terminal whose description is not found on the
machine gets the screen. A listing, `--check`, `--dry-run`, `--events`,
`--quiet`, `--no-input`, `--yes` and `--restore` keep the plain output. Options
given on the command line fill the screens in advance and are kept as they are.
However the screen ends (Enter on the result, `q`, Ctrl+C, Ctrl+D, SIGTERM, a
hangup), the terminal is given back as the screen found it: the cursor shown,
the main screen back, echo and line editing on. Ctrl+Z, or SIGTSTP from
outside, gives it back the same way and stops the run, and `fg` redraws the
screen where it was.
- `--tui` asks for the full screen, and names the reason when it cannot open
(code 2).
- `--plain`, `RETROBIOS_PLAIN=1` or `ACCESSIBLE=1` keep the questions line by
@@ -696,6 +711,8 @@ what the options name, everything by default.
## Options
<!-- generated: options -->
| Option | Effect |
|--------|--------|
| <span id="option-platform"></span>`--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. Unknown names are refused with the closest ones |
@@ -737,6 +754,8 @@ what the options name, everything by default.
| <span id="option-add-app"></span>`--add-app` | After the install, put the RetroBIOS app on the handheld card the BIOS folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS files from the console later. Refused (code 2) for a folder on no such card, and with `--check`, `--dry-run`, a listing, `--from-pack` and `--offline`. See [On a handheld](#on-a-handheld-the-retrobios-port) |
| <span id="option-help"></span>`--help`, `-h` | Print the option list, a few examples in the form the run was started with, and the address of this page |
<!-- /generated: options -->
`--check` reads the same file list, hashes the destination and exits, with
code 1 when a file is missing or does not match and 0 when every one does. Files
with a wrong hash are listed first and in full, then the missing ones whose
@@ -1142,6 +1161,8 @@ same way. Any launcher can set it to another number of seconds.
Each launcher tells the interface which buttons reach it, in
`RETROBIOS_PAD`, so the hints can name them:
<!-- generated: pad-routes -->
| Value | Input | Set by |
|-------|-------|--------|
| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port and the Tools entry |
@@ -1150,8 +1171,31 @@ Each launcher tells the interface which buttons reach it, in
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
| `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet |
<!-- /generated: pad-routes -->
A launcher with no mapper at all (a keyboard) leaves it unset.
The help screen (`?`) names the keys of the route in use. A computer gets
the keyboard column, and SteamOS game mode or a console no launcher named
gets the column that names both:
<!-- generated: route-keys -->
| Action | Computer (`keys`) | Unknown (`both`) | `gptokeyb` | `gptokeyb_muxterm` | `muxterm` | `evmapy` | `miyoo` |
|--------|-------------------|------------------|------------|--------------------|-----------|----------|---------|
| Move | Up/Down, k/j | Up/Down, k/j | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down |
| Page | PgUp/PgDn | PgUp/PgDn, L1/R1 | L1/R1, PgUp/PgDn | D-pad Left/Right | D-pad Left/Right | L1/R1, PgUp/PgDn | L1/R1, PgUp/PgDn |
| Continue, open, choose | Enter | A/Enter, Start | A, Start, Enter | A, Start, Enter | Start, Enter | A, Start, Enter | A, Start, Enter |
| Tick, untick | Space, Right/Left | X, Right/Left | X, Space, Right/Left | X, Space | Y, Space | X, Space, Right/Left | A, Space, Right/Left |
| Options | o, Tab | Y, o, Tab | Y, Tab, o | Y, Tab, o | o, Tab | Y, Tab, o | L2, Tab, o |
| Back, and quit on the first screen | Esc, Backspace | B/Esc, Backspace | B, Select, Esc | B, Select, Esc | B, Esc, Backspace | B, Select, Esc | R2, Esc, Backspace |
| Stop an install | Esc, Ctrl-C | B/Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | R2, Esc, Ctrl-C |
| Quit at once | none | Start+Select | Select, then Start | Select, then Start | none | none | none |
| Use the folder typed | Enter | Enter | A, Enter | A, Enter | Start, Enter | A, Enter | Start, Enter |
| Clear a filter, leave the folder typed | Esc | Esc | B, Esc | B, Esc | Esc | B, Esc | Esc |
<!-- /generated: route-keys -->
On a Miyoo, B, X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl:
pressed alone, a modifier sends nothing to a terminal, so the interface goes
back with R2 instead, and the bottom line says so.
@@ -1316,6 +1360,14 @@ The build scripts need 3.9 and are not bound by this.
Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and
`install.ps1` in the same commit. The tests compare the pins with the file.
The tables of options, events, exit codes and keys on this page sit between
`generated` markers and are written from `install.py` by
`scripts/generate_installer_doc.py`: the options from its parser, the events
from `EVENT_KEYS`, the codes from its `sys.exit` calls, the keys from
`TUI_ROUTES`. The prose cells stay as written. A row the code gains stops the
script until its text is added inside the markers, and `--check`, which a
test runs, fails on a table behind the code.
The [handheld](#on-a-handheld-the-retrobios-port) archives each carry
`install.py`, the one the one-line command of the same commit downloads:
`scripts/build_port.py` refuses any other (it compares it with the SHA-256
@@ -1330,6 +1382,8 @@ fields, the installer refuses to narrow rather than install everything.
## Exit status
<!-- generated: exit-codes -->
| Code | Meaning |
|------|---------|
| `0` | everything asked for is installed or already up to date |
@@ -1338,6 +1392,8 @@ fields, the installer refuses to narrow rather than install everything.
| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
<!-- /generated: exit-codes -->
The full screen ended by SIGTERM (Start+Select on a handheld) before any
install exits `143`. During an install it stops the install first, and exits
`130`. Its window or terminal closed (SIGHUP) exits `129`, during an install
@@ -1366,11 +1422,13 @@ the text a person reads goes to standard error. A program driving the installer
(the [handheld port](#on-a-handheld-the-retrobios-port) is one) reads the
objects and never parses the text. Every object has an `event` key naming its
kind, and each kind has exactly the keys below, in this order. The schema is
`EVENT_KEYS` in `install.py`, and a test holds this table to it. The same
`EVENT_KEYS` in `install.py`, and this table is written from it. The same
keys, with their types, are in `schemas/install-events.schema.json`, written
from `EVENT_KEYS` by `scripts/generate_event_schema.py` and published with the
other schemas under `api/v1/schemas/`.
<!-- generated: events -->
| Kind | Keys | When |
|------|------|------|
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
@@ -1378,9 +1436,9 @@ other schemas under `api/v1/schemas/`.
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). `message` says it in words |
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place |
| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` |
| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it |
| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place |
| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply |
| `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` |
| `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place |
@@ -1395,6 +1453,8 @@ other schemas under `api/v1/schemas/`.
| `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
<!-- /generated: events -->
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an
existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes
@@ -1425,7 +1485,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written:
| `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only |
| `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry |
| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` |
| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb` also turns the live progress line off |
| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). The full screen draws no colour at all: the row in focus is marked with `>` as well as reverse video, and every state is written in words |
| `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file |
| `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own |
| `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface |