mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-21 03:09:08 -05:00
Recalbox starts its emulators from a Python configgen too. The parsed source, the record and the ast helpers move to configgen_launchers so both readers use them. Batocera's extractors are unchanged.
202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
"""What the configgen launchers of Batocera and Recalbox have in common.
|
|
|
|
Both platforms start their emulators from a Python configgen, so their
|
|
launcher layers are read the same way: parse each generator with ast,
|
|
locate one known shape, and cite the lines it spans at the pinned revision.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from collections.abc import Callable, Iterator
|
|
from dataclasses import dataclass
|
|
from typing import Any, NoReturn
|
|
|
|
|
|
class LauncherShapeError(ValueError):
|
|
"""A generator no longer has the shape its extractor reads."""
|
|
|
|
|
|
@dataclass
|
|
class Launcher:
|
|
"""One thing a launcher looks for or hands over, as its code says it."""
|
|
|
|
emulator: str
|
|
build: str
|
|
kind: str
|
|
names: list[str]
|
|
source_ref: str
|
|
slot: str | None = None
|
|
expands: dict[str, list[str]] | None = None
|
|
case_insensitive: bool | None = None
|
|
search_dir: str | None = None
|
|
passes_to: str | None = None
|
|
fails_when_missing: bool | str = False
|
|
fails_scope: str | None = None
|
|
depends_on_option: str | None = None
|
|
device: str | None = None
|
|
|
|
def entry(self) -> dict[str, Any]:
|
|
"""The platform YAML form, in a fixed key order, unset keys left out."""
|
|
order = (
|
|
"emulator", "build", "kind", "slot", "names", "expands",
|
|
"case_insensitive", "search_dir", "passes_to",
|
|
"fails_when_missing", "fails_scope", "depends_on_option",
|
|
"device", "source_ref",
|
|
)
|
|
return {key: getattr(self, key) for key in order if getattr(self, key) is not None}
|
|
|
|
|
|
class Source:
|
|
"""One configgen file, parsed, with the lines its nodes span."""
|
|
|
|
def __init__(self, path: str, text: str) -> None:
|
|
self.path = path
|
|
self.lines = text.splitlines()
|
|
try:
|
|
self.tree = ast.parse(text)
|
|
except SyntaxError as e:
|
|
raise LauncherShapeError(f"{path}: {e}") from e
|
|
|
|
def fail(self, what: str) -> NoReturn:
|
|
raise LauncherShapeError(f"{self.path}: {what}")
|
|
|
|
def function(self, name: str) -> ast.FunctionDef:
|
|
found = [
|
|
node for node in ast.walk(self.tree)
|
|
if isinstance(node, ast.FunctionDef) and node.name == name
|
|
]
|
|
if len(found) != 1:
|
|
self.fail(f"expected one function {name}, found {len(found)}")
|
|
return found[0]
|
|
|
|
def assignment(self, name: str, scope: ast.AST | None = None) -> ast.Assign | ast.AnnAssign:
|
|
found = [
|
|
node for node in ast.walk(scope or self.tree)
|
|
if _assigned_name(node) == name
|
|
]
|
|
if len(found) != 1:
|
|
self.fail(f"expected one assignment to {name}, found {len(found)}")
|
|
return found[0]
|
|
|
|
def literal(self, node: ast.AST | None, what: str) -> Any:
|
|
try:
|
|
return ast.literal_eval(node) # type: ignore[arg-type]
|
|
except (ValueError, TypeError, SyntaxError) as e:
|
|
self.fail(f"{what} is not a literal: {e}")
|
|
|
|
def span(self, node: ast.AST) -> tuple[int, int]:
|
|
"""Lines of a node, with the comment lines written right above it."""
|
|
start = node.lineno # type: ignore[attr-defined]
|
|
while start > 1 and self.lines[start - 2].strip().startswith("#"):
|
|
start -= 1
|
|
return start, node.end_lineno # type: ignore[attr-defined]
|
|
|
|
def ref(self, spans: list[tuple[int, int]]) -> str:
|
|
"""`path:a-b, c-d`, ranges joined across blank and comment lines."""
|
|
merged: list[list[int]] = []
|
|
for start, end in sorted(spans):
|
|
if merged and self._only_filler(merged[-1][1] + 1, start - 1):
|
|
merged[-1][1] = max(merged[-1][1], end)
|
|
else:
|
|
merged.append([start, end])
|
|
parts = [f"{a}" if a == b else f"{a}-{b}" for a, b in merged]
|
|
return f"{self.path}:{', '.join(parts)}"
|
|
|
|
def _only_filler(self, first: int, last: int) -> bool:
|
|
return all(
|
|
not line.strip() or line.strip().startswith("#")
|
|
for line in self.lines[max(first - 1, 0):max(last, 0)]
|
|
)
|
|
|
|
|
|
def _assigned_name(node: ast.AST) -> str | None:
|
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
target = node.targets[0]
|
|
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
target = node.target
|
|
else:
|
|
return None
|
|
return target.id if isinstance(target, ast.Name) else None
|
|
|
|
|
|
def _statement_lists(node: ast.AST) -> Iterator[list[ast.stmt]]:
|
|
for child in ast.walk(node):
|
|
for attr in ("body", "orelse", "finalbody"):
|
|
value = getattr(child, attr, None)
|
|
if isinstance(value, list) and value and isinstance(value[0], ast.stmt):
|
|
yield value
|
|
|
|
|
|
def _locate(
|
|
scope: ast.AST, wanted: Callable[[ast.stmt], bool]
|
|
) -> tuple[list[ast.stmt], int] | None:
|
|
"""The innermost statement list holding a statement that satisfies `wanted`."""
|
|
for body in _statement_lists(scope):
|
|
for index, stmt in enumerate(body):
|
|
if wanted(stmt):
|
|
return body, index
|
|
return None
|
|
|
|
|
|
def _outermost(scope: ast.FunctionDef, node: ast.AST) -> ast.stmt:
|
|
"""The statement of the function body that contains `node`."""
|
|
for stmt in scope.body:
|
|
if any(child is node for child in ast.walk(stmt)):
|
|
return stmt
|
|
line = getattr(node, "lineno", "?")
|
|
raise LauncherShapeError(f"node at line {line} is outside {scope.name}")
|
|
|
|
|
|
def _mentions(stmt: ast.AST, constants: set[str], names: set[str]) -> bool:
|
|
for node in ast.walk(stmt):
|
|
if isinstance(node, ast.Constant) and node.value in constants:
|
|
return True
|
|
if isinstance(node, ast.Name) and node.id in names:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _run(
|
|
body: list[ast.stmt], index: int, keep: Callable[[ast.stmt], bool]
|
|
) -> tuple[ast.stmt, ast.stmt]:
|
|
"""The contiguous statements around body[index] that `keep` accepts."""
|
|
first = last = index
|
|
while first > 0 and keep(body[first - 1]):
|
|
first -= 1
|
|
while last + 1 < len(body) and keep(body[last + 1]):
|
|
last += 1
|
|
return body[first], body[last]
|
|
|
|
|
|
def _calls(scope: ast.AST, attr: str | None = None, name: str | None = None) -> list[ast.Call]:
|
|
found = []
|
|
for node in ast.walk(scope):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
if attr is not None and isinstance(func, ast.Attribute) and func.attr == attr:
|
|
found.append(node)
|
|
elif name is not None and isinstance(func, ast.Name) and func.id == name:
|
|
found.append(node)
|
|
return sorted(found, key=lambda call: (call.lineno, call.col_offset))
|
|
|
|
|
|
def _enclosing_test(scope: ast.AST, node: ast.AST) -> str | None:
|
|
for parent in ast.walk(scope):
|
|
if isinstance(parent, ast.If) and any(
|
|
child is node for stmt in parent.body for child in ast.walk(stmt)
|
|
):
|
|
return ast.unparse(parent.test)
|
|
return None
|
|
|
|
|
|
def _innermost_function(src: Source, node: ast.AST) -> ast.FunctionDef:
|
|
holders = [
|
|
f for f in ast.walk(src.tree)
|
|
if isinstance(f, ast.FunctionDef) and any(child is node for child in ast.walk(f))
|
|
]
|
|
if not holders:
|
|
src.fail(f"line {node.lineno} is outside any function") # type: ignore[attr-defined]
|
|
return max(holders, key=lambda f: f.lineno)
|