mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-21 11:19:11 -05:00
feat: read RetroPie launchers from scriptmodules
The scraper walks each package's functions the way RetroPie runs them in one shell (assignments, arrays, for loops, brace expansion, $md_id, calls into other packages) and records every link, copy, move and setting that names $biosdir, the addEmulator flags (sdltrs -romfile3), the BIOS read outside $biosdir (basilisk --rom, gngeopi -B) and the launch scripts that exit without their file (fs-uae kick13.rom, reicast dc_boot.bin). Each package also gets a package record with its rp_module_repo and the systems it registers, which maps a package to the build it compiles. The schema gains package, repo, systems and the moves and package kinds. platforms/retropie.yml is regenerated at 46128cd2: only the launchers section is new.
This commit is contained in:
1 parent
1c23d4daaa
commit
15ffeddfe3
7 files changed
+3923
-6
No files matched your search
@@ -0,0 +1,995 @@
|
||||
"""RetroPie's launcher layer, read from the scriptmodules.
|
||||
|
||||
RetroPie publishes no BIOS list, but its package scripts decide where a file
|
||||
dropped in ~/RetroPie/BIOS ends up: px68k links five names into ~/.keropi,
|
||||
redream links dc_boot.bin under another name, sdltrs passes one ROM per
|
||||
machine on its command line, the fs-uae launch script refuses to start
|
||||
without kick13.rom. Each package also says which project it builds
|
||||
(`rp_module_repo`) and which systems it registers (`addSystem`), which is
|
||||
what ties a package to the emulator profile describing that build.
|
||||
|
||||
The bash is read as text, never run. A small evaluator follows what the
|
||||
scripts do between a function's braces: local assignments, arrays, `for`
|
||||
loops, brace expansion, `$md_id` and calls into other packages' functions,
|
||||
the way RetroPie runs them in one shell. Every line of a package function
|
||||
that names the BIOS directory must match a known construct, else the scrape
|
||||
stops with LauncherPatternMissing: a use nobody classified would otherwise
|
||||
drop out of the platform file without a word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import product
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
SCRIPTMODULES = "scriptmodules"
|
||||
SECTIONS = ("emulators", "libretrocores", "ports")
|
||||
PACKAGES_SCRIPT = "retropie_packages.sh"
|
||||
|
||||
BIOSDIR = "$biosdir"
|
||||
# The package whose config every lr- core runs under.
|
||||
LIBRETRO_FRONTEND = "retroarch"
|
||||
# The launch scripts copied next to an emulator spell the directory out.
|
||||
BIOS_SPELLED = "RetroPie/BIOS"
|
||||
|
||||
# Commands whose $biosdir argument creates, owns or deletes a path and hands
|
||||
# nothing to an emulator.
|
||||
HOUSEKEEPING = frozenset({"mkUserDir", "chown", "chmod", "rm", "mkdir", "iniConfig"})
|
||||
|
||||
# Arguments that name a BIOS file or directory outside $biosdir, per package,
|
||||
# as the emulator documents the flag. Each must still be in the scriptmodule.
|
||||
OUTSIDE_BIOS: dict[str, tuple[str, ...]] = {
|
||||
"basilisk": ("--rom",), # BasiliskII --rom <ROM file>
|
||||
"gngeopi": ("-B",), # gngeo -B/--biospath <directory of neogeo.zip>
|
||||
}
|
||||
|
||||
_FUNCTION = re.compile(r"^function\s+([\w.+-]+)\s*\(\)\s*\{\s*$")
|
||||
_ASSIGN = re.compile(r"^(?:local\s+)?([A-Za-z_]\w*)=(.*)$")
|
||||
_DECLARE = re.compile(r"^local\s+([A-Za-z_]\w*(?:\s+[A-Za-z_]\w*)*)\s*$")
|
||||
_FOR = re.compile(r"^for\s+([A-Za-z_]\w*)\s+in\s+(.*?)\s*(?:;\s*do)?\s*$")
|
||||
_HEREDOC = re.compile(r"(?<!<)<<-?(?!<)\s*['\"]?(\w+)['\"]?")
|
||||
_VAR = re.compile(r"\$\{([A-Za-z_]\w*)(\[@\])?\}|\$([A-Za-z_]\w*|\d)")
|
||||
_SUBST = re.compile(r"\$\(([\w.+-]+(?:\$\{md_id\}|\$md_id)?)\)")
|
||||
_MD_ID_TEST = re.compile(r'^!?\s*\[\[\s*"\$md_id"\s*(==|!=)\s*"?([\w.+-]+)"?\s*\]\]$')
|
||||
_MODULE_FIELD = re.compile(r'^rp_module_(id|repo)="([^"]*)"', re.MULTILINE)
|
||||
_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,5}$")
|
||||
|
||||
|
||||
class LauncherPatternMissing(ValueError):
|
||||
"""A construct an extractor reads is absent from the scriptmodules."""
|
||||
|
||||
def __init__(self, path: str, what: str) -> None:
|
||||
super().__init__(f"{path}: {what}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Launcher:
|
||||
"""One thing a package hands an emulator, or the package itself."""
|
||||
|
||||
emulator: str
|
||||
build: str
|
||||
kind: str
|
||||
names: list[str]
|
||||
source_ref: str
|
||||
package: str | None = None
|
||||
repo: str | None = None
|
||||
systems: list[str] | None = None
|
||||
slot: str | None = None
|
||||
case_insensitive: bool | None = None
|
||||
search_dir: str | None = None
|
||||
passes_to: str | None = None
|
||||
fails_when_missing: bool | str | None = None
|
||||
depends_on_option: str | None = None
|
||||
lines: list[tuple[str, int]] = field(default_factory=list, repr=False)
|
||||
|
||||
def entry(self) -> dict[str, Any]:
|
||||
"""The platform YAML form, in a fixed key order, unset keys left out."""
|
||||
order = (
|
||||
"emulator", "package", "repo", "systems", "build", "kind", "slot",
|
||||
"names", "case_insensitive", "search_dir", "passes_to",
|
||||
"fails_when_missing", "depends_on_option", "source_ref",
|
||||
)
|
||||
return {key: getattr(self, key) for key in order if getattr(self, key) is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Module:
|
||||
"""One package script: its id, section, lines and functions."""
|
||||
|
||||
path: str
|
||||
section: str
|
||||
lines: list[str]
|
||||
id: str
|
||||
id_line: int
|
||||
repo: str | None
|
||||
repo_line: int | None
|
||||
functions: dict[str, tuple[int, int]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def build(self) -> str:
|
||||
libretro = self.section == "libretrocores" or self.id == LIBRETRO_FRONTEND
|
||||
return "libretro" if libretro else "standalone"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""A statement reached while walking a package's functions."""
|
||||
|
||||
path: str
|
||||
line: int
|
||||
words: list[list[str]]
|
||||
raw: str
|
||||
conditions: list[str]
|
||||
heredoc: bool
|
||||
opener: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _logical_lines(
|
||||
lines: list[str], start: int, end: int
|
||||
) -> Iterator[tuple[int, str, str | None]]:
|
||||
"""(line number, text, heredoc opener or None) for lines start..end, continuations joined."""
|
||||
tag: str | None = None
|
||||
opener = ""
|
||||
number = start
|
||||
while number <= end:
|
||||
text = lines[number - 1]
|
||||
first = number
|
||||
if tag is not None:
|
||||
if text.strip() == tag:
|
||||
tag = None
|
||||
else:
|
||||
yield first, text, opener
|
||||
number += 1
|
||||
continue
|
||||
while text.rstrip().endswith("\\") and number < end:
|
||||
number += 1
|
||||
text = text.rstrip()[:-1] + " " + lines[number - 1].strip()
|
||||
opened = _HEREDOC.search(text)
|
||||
if opened is not None:
|
||||
tag = opened.group(1)
|
||||
opener = text.strip()
|
||||
yield first, text, None
|
||||
number += 1
|
||||
|
||||
|
||||
def _brace(word: str) -> list[str]:
|
||||
"""Bash brace expansion of comma lists, outside variable references."""
|
||||
found = re.search(r"(?<!\$)\{([^{}$]*,[^{}$]*)\}", word)
|
||||
if found is None:
|
||||
return [word]
|
||||
head, tail = word[: found.start()], word[found.end():]
|
||||
return [
|
||||
expanded
|
||||
for option in found.group(1).split(",")
|
||||
for expanded in _brace(head + option + tail)
|
||||
]
|
||||
|
||||
|
||||
def _split(text: str) -> list[str]:
|
||||
"""Shell words with quotes removed, a line bash itself would accept."""
|
||||
try:
|
||||
return shlex.split(text, comments=True)
|
||||
except ValueError:
|
||||
return text.split()
|
||||
|
||||
|
||||
class Scripts:
|
||||
"""Every package script, with the functions RetroPie loads into one shell."""
|
||||
|
||||
def __init__(self, files: dict[str, str]) -> None:
|
||||
self.files = files
|
||||
self.modules: dict[str, Module] = {}
|
||||
self.owner: dict[str, Module] = {}
|
||||
self._walked: dict[str, list[Event]] = {}
|
||||
for path in sorted(files):
|
||||
parts = PurePosixPath(path).parts
|
||||
is_package = (
|
||||
len(parts) == 3
|
||||
and parts[0] == SCRIPTMODULES
|
||||
and parts[1] in SECTIONS
|
||||
and parts[2].endswith(".sh")
|
||||
)
|
||||
if is_package:
|
||||
module = self._parse(path, parts[1], files[path])
|
||||
if module is not None:
|
||||
self.modules[module.id] = module
|
||||
if not self.modules:
|
||||
raise LauncherPatternMissing(SCRIPTMODULES, "no package script declares rp_module_id")
|
||||
for module in self.modules.values():
|
||||
for name in module.functions:
|
||||
self.owner[name] = module
|
||||
|
||||
@staticmethod
|
||||
def _parse(path: str, section: str, text: str) -> Module | None:
|
||||
lines = text.splitlines()
|
||||
fields: dict[str, tuple[str, int]] = {}
|
||||
for found in _MODULE_FIELD.finditer(text):
|
||||
line = text.count("\n", 0, found.start()) + 1
|
||||
fields.setdefault(found.group(1), (found.group(2), line))
|
||||
if "id" not in fields:
|
||||
return None
|
||||
repo = fields.get("repo")
|
||||
module = Module(
|
||||
path=path, section=section, lines=lines,
|
||||
id=fields["id"][0], id_line=fields["id"][1],
|
||||
repo=repo[0] if repo else None, repo_line=repo[1] if repo else None,
|
||||
)
|
||||
opened: tuple[str, int] | None = None
|
||||
tag: str | None = None
|
||||
for number, line in enumerate(lines, 1):
|
||||
# A generated script may define functions of its own.
|
||||
if tag is not None:
|
||||
if line.strip() == tag:
|
||||
tag = None
|
||||
continue
|
||||
heredoc = _HEREDOC.search(line)
|
||||
if heredoc is not None:
|
||||
tag = heredoc.group(1)
|
||||
header = _FUNCTION.match(line)
|
||||
if header is not None:
|
||||
opened = (header.group(1), number)
|
||||
elif opened is not None and line.rstrip() == "}":
|
||||
module.functions[opened[0]] = (opened[1], number)
|
||||
opened = None
|
||||
if opened is not None:
|
||||
raise LauncherPatternMissing(path, f"function {opened[0]} has no closing brace")
|
||||
return module
|
||||
|
||||
def echo_value(self, name: str) -> str | None:
|
||||
"""What a function whose whole body is one `echo "X"` prints."""
|
||||
module = self.owner.get(name)
|
||||
if module is None:
|
||||
return None
|
||||
start, end = module.functions[name]
|
||||
body = [line.strip() for line in module.lines[start:end - 1] if line.strip()]
|
||||
if len(body) == 1:
|
||||
words = _split(body[0])
|
||||
if len(words) == 2 and words[0] == "echo":
|
||||
return words[1]
|
||||
return None
|
||||
|
||||
def walk(self, package: Module) -> list[Event]:
|
||||
"""Every statement of a package's functions, as RetroPie runs them for it."""
|
||||
if package.id not in self._walked:
|
||||
events: dict[tuple[str, int], Event] = {}
|
||||
for name in package.functions:
|
||||
self._walk(package, name, {}, [], set(), events)
|
||||
self._walked[package.id] = sorted(
|
||||
events.values(), key=lambda e: (e.path != package.path, e.path, e.line)
|
||||
)
|
||||
return self._walked[package.id]
|
||||
|
||||
def _walk(
|
||||
self,
|
||||
package: Module,
|
||||
name: str,
|
||||
args: dict[str, list[str]],
|
||||
conditions: list[str],
|
||||
stack: set[str],
|
||||
events: dict[tuple[str, int], Event],
|
||||
) -> None:
|
||||
if name in stack:
|
||||
return
|
||||
module = self.owner[name]
|
||||
start, end = module.functions[name]
|
||||
env: dict[str, list[str]] = dict(args)
|
||||
loops: list[tuple[str | None, list[str] | None]] = []
|
||||
branches: list[tuple[str, bool | None]] = []
|
||||
array: tuple[str, list[str]] | None = None
|
||||
for number, text, opener in _logical_lines(module.lines, start + 1, end - 1):
|
||||
stripped = text.strip()
|
||||
if array is not None:
|
||||
body = re.sub(r"\s+#.*$", "", stripped)
|
||||
closed = body.endswith(")")
|
||||
array[1].append(body[:-1] if closed else body)
|
||||
if closed:
|
||||
words = _split(" ".join(array[1]))
|
||||
env[array[0]] = [v for w in words for v in self.expand(w, package, env)]
|
||||
array = None
|
||||
continue
|
||||
if opener is not None:
|
||||
if self._live(branches):
|
||||
words = [self.expand(w, package, env) for w in stripped.split()]
|
||||
events.setdefault((module.path, number), Event(
|
||||
module.path, number, words, stripped,
|
||||
conditions + self._shown(branches, package, env), True,
|
||||
[self.expand(w, package, env) for w in _split(opener)],
|
||||
))
|
||||
continue
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if self._branch(stripped, package, env, branches):
|
||||
continue
|
||||
if not self._live(branches):
|
||||
continue
|
||||
loop = _FOR.match(stripped)
|
||||
if loop is not None:
|
||||
values = [v for w in _split(loop.group(2)) for v in self.expand(w, package, env)]
|
||||
loops.append((loop.group(1), env.get(loop.group(1))))
|
||||
env[loop.group(1)] = values
|
||||
continue
|
||||
if re.match(r"^(while|until)\b|^for\s*\(\(", stripped):
|
||||
loops.append((None, None))
|
||||
continue
|
||||
if stripped == "done" or stripped.startswith("done ") or stripped.startswith("done;"):
|
||||
if loops:
|
||||
var, previous = loops.pop()
|
||||
if var is not None:
|
||||
if previous is None:
|
||||
env.pop(var, None)
|
||||
else:
|
||||
env[var] = previous
|
||||
continue
|
||||
if stripped in ("do", "then", "else", "fi", "esac", "{", "}"):
|
||||
continue
|
||||
guard = re.match(r"^\[\[\s*(.*?)\s*\]\]\s*&&\s*(.*)$", stripped)
|
||||
shown = conditions + self._shown(branches, package, env)
|
||||
if guard is not None:
|
||||
verdict = self._md_id_test(f"[[ {guard.group(1)} ]]", package)
|
||||
if verdict is False or "$md_mode" in guard.group(1) or "$mode" in guard.group(1):
|
||||
continue
|
||||
if verdict is None:
|
||||
shown = shown + [self._render(guard.group(1), package, env)]
|
||||
stripped = guard.group(2)
|
||||
opens = re.match(r"^(?:local\s+)?([A-Za-z_]\w*)=\(([^)]*)$", stripped)
|
||||
if opens is not None:
|
||||
array = (opens.group(1), [opens.group(2)])
|
||||
continue
|
||||
if self._bind(stripped, package, env):
|
||||
continue
|
||||
words = [self.expand(w, package, env) for w in _split(stripped)]
|
||||
# The first walk to reach a line is the one with its caller's arguments.
|
||||
events.setdefault(
|
||||
(module.path, number), Event(module.path, number, words, stripped, shown, False)
|
||||
)
|
||||
callee = words[0][0] if words and words[0] else None
|
||||
if callee in self.owner and callee != name:
|
||||
call_args = {str(i): w for i, w in enumerate(words[1:], 1)}
|
||||
self._walk(package, callee, call_args, shown, stack | {name}, events)
|
||||
|
||||
def _branch(
|
||||
self, stripped: str, package: Module, env: dict[str, list[str]],
|
||||
branches: list[tuple[str, bool | None]],
|
||||
) -> bool:
|
||||
"""Track if/elif/else/fi, and say whether the line was one of them."""
|
||||
opened = re.match(r"^(if|elif)\s+(.*?)\s*(?:;\s*then)?\s*$", stripped)
|
||||
if opened is not None:
|
||||
condition = opened.group(2)
|
||||
verdict = self._md_id_test(condition, package)
|
||||
if "$md_mode" in condition or "$mode" in condition:
|
||||
verdict = True if "install" in condition else None
|
||||
if opened.group(1) == "if":
|
||||
branches.append((condition, verdict))
|
||||
elif branches:
|
||||
previous, previous_verdict = branches[-1]
|
||||
if previous_verdict is True:
|
||||
verdict = False
|
||||
branches[-1] = (condition, verdict)
|
||||
return True
|
||||
if stripped == "else" and branches:
|
||||
condition, verdict = branches[-1]
|
||||
branches[-1] = (f"not ({condition})", None if verdict is None else not verdict)
|
||||
return True
|
||||
if stripped == "fi" or stripped.startswith("fi "):
|
||||
if branches:
|
||||
branches.pop()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _live(branches: list[tuple[str, bool | None]]) -> bool:
|
||||
return all(verdict is not False for _c, verdict in branches)
|
||||
|
||||
def _shown(
|
||||
self, branches: list[tuple[str, bool | None]], package: Module, env: dict[str, list[str]]
|
||||
) -> list[str]:
|
||||
return [
|
||||
self._render(condition, package, env)
|
||||
for condition, verdict in branches if verdict is None
|
||||
]
|
||||
|
||||
def _render(self, condition: str, package: Module, env: dict[str, list[str]]) -> str:
|
||||
"""A condition with its variables filled in when each takes one value."""
|
||||
values = self.expand(condition, package, env)
|
||||
return values[0] if len(values) == 1 else condition
|
||||
|
||||
@staticmethod
|
||||
def _md_id_test(condition: str, package: Module) -> bool | None:
|
||||
test = _MD_ID_TEST.match(condition.strip())
|
||||
if test is None:
|
||||
return None
|
||||
equal = package.id == test.group(2)
|
||||
return equal if test.group(1) == "==" else not equal
|
||||
|
||||
def _bind(self, stripped: str, package: Module, env: dict[str, list[str]]) -> bool:
|
||||
"""Record a variable assignment, and say whether the line was one."""
|
||||
if _DECLARE.match(stripped):
|
||||
return True
|
||||
assign = _ASSIGN.match(stripped)
|
||||
if assign is None:
|
||||
return False
|
||||
name, value = assign.group(1), assign.group(2).strip()
|
||||
if value.startswith("("):
|
||||
closing = value.rfind(")")
|
||||
inner = value[1:closing] if closing > 0 else value[1:]
|
||||
env[name] = [v for w in _split(inner) for v in self.expand(w, package, env)]
|
||||
return True
|
||||
words = _split(value) or [""]
|
||||
if "$(" in words[0] and _SUBST.fullmatch(words[0]) is None:
|
||||
# A command's output: the variable stays a reference.
|
||||
env.pop(name, None)
|
||||
return True
|
||||
expanded = self.expand(words[0], package, env)
|
||||
if any("$(" in v for v in expanded):
|
||||
env.pop(name, None)
|
||||
else:
|
||||
env[name] = expanded
|
||||
return True
|
||||
|
||||
def expand(self, word: str, package: Module, env: dict[str, list[str]]) -> list[str]:
|
||||
"""Every value a word takes: braces, $md_id, bound variables, $(echo functions)."""
|
||||
word = re.sub(r"\$\{md_id\}|\$md_id\b", package.id, word)
|
||||
|
||||
def substitute(found: re.Match[str]) -> str:
|
||||
printed = self.echo_value(found.group(1))
|
||||
return printed if printed is not None else found.group(0)
|
||||
|
||||
word = _SUBST.sub(substitute, word)
|
||||
results: list[str] = []
|
||||
for braced in _brace(word):
|
||||
names = [m.group(1) or m.group(3) for m in _VAR.finditer(braced)]
|
||||
bound = [n for n in names if n in env]
|
||||
if not bound:
|
||||
results.append(braced)
|
||||
continue
|
||||
for values in product(*(env[n] for n in bound)):
|
||||
chosen = dict(zip(bound, values))
|
||||
|
||||
def replace(found: re.Match[str]) -> str:
|
||||
key = found.group(1) or found.group(3)
|
||||
return chosen.get(key, found.group(0))
|
||||
|
||||
results.append(_VAR.sub(replace, braced))
|
||||
return results
|
||||
|
||||
|
||||
def _mentions_bios(text: str) -> bool:
|
||||
return BIOSDIR in text or "${biosdir}" in text or BIOS_SPELLED in text
|
||||
|
||||
|
||||
def _is_file(path: str) -> bool:
|
||||
return bool(_EXTENSION.search(PurePosixPath(path).name)) and "*" not in path
|
||||
|
||||
|
||||
def _split_path(path: str) -> tuple[str, str]:
|
||||
pure = PurePosixPath(path.rstrip("/"))
|
||||
return str(pure.parent), pure.name
|
||||
|
||||
|
||||
class PackageReader:
|
||||
"""Launcher records of one package, from the statements its functions run."""
|
||||
|
||||
def __init__(self, scripts: Scripts, package: Module) -> None:
|
||||
self.scripts = scripts
|
||||
self.package = package
|
||||
self.records: list[Launcher] = []
|
||||
self.installed_as: dict[str, str] = {}
|
||||
|
||||
def record(
|
||||
self, kind: str, names: list[str], lines: list[tuple[str, int]], **fields: Any
|
||||
) -> None:
|
||||
self.records.append(Launcher(
|
||||
emulator=fields.pop("emulator", self.package.id),
|
||||
package=self.package.id,
|
||||
build=self.package.build,
|
||||
kind=kind,
|
||||
names=names,
|
||||
source_ref=source_ref(lines),
|
||||
lines=list(lines),
|
||||
**fields,
|
||||
))
|
||||
|
||||
def final(self, path: str) -> str:
|
||||
"""The file a scratch config ends up as, from the copyDefaultConfig that installs it."""
|
||||
return self.installed_as.get(path, path)
|
||||
|
||||
def read(self) -> list[Launcher]:
|
||||
events = self.scripts.walk(self.package)
|
||||
for event in events:
|
||||
if not event.heredoc and event.words and event.words[0] == ["copyDefaultConfig"]:
|
||||
if len(event.words) >= 3 and len(event.words[1]) == 1 and len(event.words[2]) == 1:
|
||||
self.installed_as.setdefault(event.words[1][0], event.words[2][0])
|
||||
links: list[Event] = []
|
||||
for event in events:
|
||||
if not event.words or not event.words[0]:
|
||||
continue
|
||||
if links and not self._same_link_run(links[-1], event):
|
||||
self._links(links)
|
||||
links = []
|
||||
if self._is_bios_link(event):
|
||||
links.append(event)
|
||||
continue
|
||||
self._classify(event)
|
||||
if links:
|
||||
self._links(links)
|
||||
self._outside_bios(events)
|
||||
return self.records
|
||||
|
||||
@staticmethod
|
||||
def _depends(event: Event) -> str | None:
|
||||
return "; ".join(event.conditions) if event.conditions else None
|
||||
|
||||
def _classify(self, event: Event) -> None:
|
||||
command = event.words[0][0]
|
||||
if event.heredoc:
|
||||
if _mentions_bios(event.raw):
|
||||
self._heredoc_setting(event)
|
||||
return
|
||||
if command == "addEmulator":
|
||||
self._add_emulator(event)
|
||||
return
|
||||
if not _mentions_bios(event.raw):
|
||||
return
|
||||
if command in HOUSEKEEPING:
|
||||
return
|
||||
handlers = {
|
||||
"cp": self._copy,
|
||||
"runCmd": self._run_cmd,
|
||||
"unzip": self._unzip,
|
||||
"mv": self._move,
|
||||
"moveConfigFile": self._move,
|
||||
"moveConfigDir": self._move_dir,
|
||||
"iniSet": self._ini_set,
|
||||
"copyDefaultConfig": self._written,
|
||||
"cat": self._written,
|
||||
"echo": self._written,
|
||||
"cmake": self._build_flag,
|
||||
}
|
||||
handler = handlers.get(command)
|
||||
if handler is None:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"unrecognised use of the BIOS directory: {event.raw}"
|
||||
)
|
||||
handler(event)
|
||||
|
||||
def _is_bios_link(self, event: Event) -> bool:
|
||||
words = event.words
|
||||
if words[0][0] != "ln" or not _mentions_bios(event.raw):
|
||||
return False
|
||||
paths = [w for w in words[1:] if not w[0].startswith("-")]
|
||||
if len(paths) != 2 or not all(_mentions_bios(v) for v in paths[0]):
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"link without a BIOS source: {event.raw}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _same_link_run(previous: Event, event: Event) -> bool:
|
||||
return (
|
||||
event.words[0][0] == "ln"
|
||||
and previous.path == event.path
|
||||
and event.line - previous.line <= 2
|
||||
)
|
||||
|
||||
def _links(self, events: list[Event]) -> None:
|
||||
sources: list[str] = []
|
||||
targets: list[str] = []
|
||||
for event in events:
|
||||
paths = [w for w in event.words[1:] if not w[0].startswith("-")]
|
||||
source, target = paths
|
||||
if len(target) == 1 and len(source) > 1:
|
||||
target = [f"{target[0].rstrip('/')}/{_split_path(s)[1]}" for s in source]
|
||||
sources += source
|
||||
targets += target
|
||||
folders = {_split_path(s)[0] for s in sources}
|
||||
whole_dir = all(
|
||||
not _is_file(s) and "/" not in s.replace(BIOSDIR, "", 1).strip("/") for s in sources
|
||||
)
|
||||
if whole_dir:
|
||||
names: list[str] = []
|
||||
search_dir = sources[0]
|
||||
passes_to = f"{targets[0]} is a symlink to it"
|
||||
else:
|
||||
if len(folders) != 1:
|
||||
raise LauncherPatternMissing(
|
||||
f"{events[0].path}:{events[0].line}",
|
||||
"links from several BIOS folders in one run",
|
||||
)
|
||||
search_dir = folders.pop()
|
||||
names = [_split_path(s)[1] for s in sources]
|
||||
passes_to = _render_targets(names, targets)
|
||||
self.record(
|
||||
"links", names, [(e.path, e.line) for e in events],
|
||||
case_insensitive=False, search_dir=search_dir, passes_to=passes_to,
|
||||
fails_when_missing=False, depends_on_option=self._depends(events[0]),
|
||||
)
|
||||
|
||||
def _copy(self, event: Event) -> None:
|
||||
paths = [v for w in event.words[1:] if not w[0].startswith("-") for v in w]
|
||||
*sources, target = paths
|
||||
if not _mentions_bios(target):
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"copy out of the BIOS directory: {event.raw}"
|
||||
)
|
||||
folders = {_split_path(s)[0] for s in sources}
|
||||
if len(folders) != 1:
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", "copy from several folders")
|
||||
self.record(
|
||||
"copies", [_split_path(s)[1] for s in sources], [(event.path, event.line)],
|
||||
search_dir=folders.pop(), passes_to=target.rstrip("/"),
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _run_cmd(self, event: Event) -> None:
|
||||
inner = Event(event.path, event.line, event.words[1:], event.raw, event.conditions, False)
|
||||
if not inner.words or inner.words[0][0] != "unzip":
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"runCmd of {event.raw}")
|
||||
self._unzip(inner)
|
||||
|
||||
def _unzip(self, event: Event) -> None:
|
||||
values = [v for w in event.words[1:] for v in w]
|
||||
if "-d" not in values:
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", "unzip without -d")
|
||||
target = values[values.index("-d") + 1]
|
||||
archives = [v for v in values if v.endswith(".zip")]
|
||||
if len(archives) != 1 or not _mentions_bios(target):
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"unzip of {event.raw}")
|
||||
self.record(
|
||||
"copies", [], [(event.path, event.line)],
|
||||
search_dir=archives[0], passes_to=target,
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _move(self, event: Event) -> None:
|
||||
source, target = (w for w in event.words[1:] if not w[0].startswith("-"))
|
||||
if not all(_mentions_bios(v) for v in source + target):
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"move across folders: {event.raw}"
|
||||
)
|
||||
folders = {_split_path(s)[0] for s in source}
|
||||
destinations = {_split_path(t)[0] for t in target}
|
||||
if len(folders) != 1 or len(destinations) != 1:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", "move between several folders"
|
||||
)
|
||||
self.record(
|
||||
"moves", [_split_path(s)[1] for s in source], [(event.path, event.line)],
|
||||
case_insensitive=False, search_dir=folders.pop(), passes_to=destinations.pop(),
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _move_dir(self, event: Event) -> None:
|
||||
link, real = (w[0] for w in event.words[1:3])
|
||||
if not _mentions_bios(real) or _mentions_bios(link):
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"moveConfigDir into a non-BIOS folder: {event.raw}"
|
||||
)
|
||||
self.record(
|
||||
"links", [], [(event.path, event.line)],
|
||||
search_dir=real, passes_to=f"{link} is a symlink to it",
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _ini_set(self, event: Event) -> None:
|
||||
key, value = event.words[1][0], event.words[2][0]
|
||||
target = self._ini_file(event)
|
||||
passes_to = f"{key} in {self.final(target)}" if target else key
|
||||
if _is_file(value) and ";" not in value:
|
||||
folder, name = _split_path(value)
|
||||
self.record(
|
||||
"fixed_name", [name], [(event.path, event.line)],
|
||||
case_insensitive=False, search_dir=folder, passes_to=passes_to,
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
return
|
||||
self.record(
|
||||
"passes_path", [], [(event.path, event.line)],
|
||||
search_dir=value, passes_to=passes_to,
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _ini_file(self, event: Event) -> str | None:
|
||||
"""The file the closest iniConfig above this iniSet points at."""
|
||||
if len(event.words) > 3:
|
||||
return event.words[3][0]
|
||||
lines = self.scripts.files[event.path].splitlines()
|
||||
for number in range(event.line - 1, 0, -1):
|
||||
words = _split(lines[number - 1].strip())
|
||||
if words and words[0] == "iniConfig" and len(words) >= 4:
|
||||
return words[3]
|
||||
if _FUNCTION.match(lines[number - 1]):
|
||||
break
|
||||
return None
|
||||
|
||||
def _written(self, event: Event) -> None:
|
||||
"""A file the package writes into the BIOS directory itself."""
|
||||
target = next(
|
||||
(v for w in reversed(event.words[1:]) for v in w if _mentions_bios(v)), None
|
||||
)
|
||||
if target is None or not _is_file(target):
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"write of {event.raw}")
|
||||
folder, name = _split_path(target)
|
||||
self.record(
|
||||
"copies", [name], [(event.path, event.line)],
|
||||
search_dir=None, passes_to=folder,
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _build_flag(self, event: Event) -> None:
|
||||
flags = [
|
||||
v for w in event.words[1:] for v in w
|
||||
if v.startswith("-D") and "=" in v and _mentions_bios(v)
|
||||
]
|
||||
if not flags:
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"cmake of {event.raw}")
|
||||
for flag in flags:
|
||||
option, value = flag.split("=", 1)
|
||||
self.record(
|
||||
"passes_path", [], [(event.path, event.line)],
|
||||
search_dir=value, passes_to=f"build option {option}",
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _heredoc_setting(self, event: Event) -> None:
|
||||
"""A `KEY = $biosdir/...` or `--flag="$biosdir"` line of a generated file."""
|
||||
text = event.raw
|
||||
setting = re.match(r'^([\w.-]+)\s*=\s*"?([^"]*)"?$', text)
|
||||
flag = re.match(r'^(--?[\w-]+)(?:=|\s+)"?([^"]*)"?$', text)
|
||||
found = flag or setting
|
||||
if found is None:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"generated line names the BIOS directory: {text}"
|
||||
)
|
||||
key, value = found.group(1), found.group(2)
|
||||
kind, names, folder = "passes_path", [], value
|
||||
if _is_file(value):
|
||||
folder, name = _split_path(value)
|
||||
kind, names = "fixed_name", [name]
|
||||
written = _redirect_target(event.opener)
|
||||
if written is None:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", "generated file with no > or >> target"
|
||||
)
|
||||
self.record(
|
||||
kind, names, [(event.path, event.line)],
|
||||
case_insensitive=False if kind == "fixed_name" else None,
|
||||
search_dir=folder, passes_to=f"{key} in {self.final(written)}",
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _add_emulator(self, event: Event) -> None:
|
||||
if len(event.words) < 5:
|
||||
return
|
||||
command = event.words[4][0]
|
||||
if not _mentions_bios(command):
|
||||
return
|
||||
names = event.words[2]
|
||||
tokens = _split(command)
|
||||
found = [
|
||||
(flag, value) for flag, value in zip(tokens, tokens[1:])
|
||||
if flag.startswith("-") and _mentions_bios(value)
|
||||
]
|
||||
found += [
|
||||
tuple(token.split("=", 1)) for token in tokens
|
||||
if token.startswith("-") and "=" in token and _mentions_bios(token.split("=", 1)[1])
|
||||
]
|
||||
if not found:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}",
|
||||
f"addEmulator names the BIOS directory without a flag: {command}",
|
||||
)
|
||||
for emulator in names:
|
||||
for flag, value in found:
|
||||
self._command_line(event, emulator, flag, value)
|
||||
|
||||
def _command_line(self, event: Event, emulator: str, flag: str, value: str) -> None:
|
||||
folder, name = _split_path(value)
|
||||
if _is_file(value):
|
||||
kind, names, search = "fixed_name", [name], folder
|
||||
else:
|
||||
kind, names, search = "passes_path", [], value
|
||||
self.record(
|
||||
kind, names, [(event.path, event.line)], emulator=emulator,
|
||||
case_insensitive=False if kind == "fixed_name" else None,
|
||||
search_dir=search, passes_to=f"command line {flag}",
|
||||
fails_when_missing=False, depends_on_option=self._depends(event),
|
||||
)
|
||||
|
||||
def _outside_bios(self, events: list[Event]) -> None:
|
||||
flags = OUTSIDE_BIOS.get(self.package.id)
|
||||
if not flags:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
for event in events:
|
||||
if event.words[0][0] != "addEmulator" or len(event.words) < 5:
|
||||
continue
|
||||
tokens = _split(event.words[4][0])
|
||||
for flag, value in zip(tokens, tokens[1:]):
|
||||
if flag in flags:
|
||||
seen.add(flag)
|
||||
for emulator in event.words[2]:
|
||||
self._command_line(event, emulator, flag, value)
|
||||
missing = sorted(set(flags) - seen)
|
||||
if missing:
|
||||
raise LauncherPatternMissing(
|
||||
self.package.path, f"addEmulator no longer passes {missing}"
|
||||
)
|
||||
|
||||
|
||||
def _redirect_target(words: list[list[str]]) -> str | None:
|
||||
"""The file a `cat > FILE << EOF` or `cat << EOF >> FILE` line writes."""
|
||||
flat = [w[0] for w in words if w]
|
||||
for index, word in enumerate(flat[:-1]):
|
||||
if word in (">", ">>"):
|
||||
return flat[index + 1]
|
||||
return None
|
||||
|
||||
|
||||
def merge_records(records: list[Launcher]) -> list[Launcher]:
|
||||
"""Records equal but for their lines become one, citing every line."""
|
||||
merged: dict[tuple[Any, ...], Launcher] = {}
|
||||
for record in records:
|
||||
entry = record.entry()
|
||||
entry.pop("source_ref")
|
||||
key = tuple((k, repr(v)) for k, v in entry.items())
|
||||
if key in merged:
|
||||
kept = merged[key]
|
||||
kept.lines += record.lines
|
||||
kept.source_ref = source_ref(kept.lines)
|
||||
else:
|
||||
merged[key] = record
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def _render_targets(names: list[str], targets: list[str]) -> str:
|
||||
"""Where linked names land: one folder when the names are kept, each path when renamed."""
|
||||
folders = {_split_path(t)[0] for t in targets}
|
||||
kept = [_split_path(t)[1] for t in targets] == names
|
||||
if kept and len(folders) == 1:
|
||||
return f"{folders.pop()}/<name>"
|
||||
return ", ".join(targets)
|
||||
|
||||
|
||||
def source_ref(lines: list[tuple[str, int]]) -> str:
|
||||
"""`path:a-b, c` per file, in first-seen file order, adjacent lines joined."""
|
||||
by_path: dict[str, list[int]] = {}
|
||||
for path, number in lines:
|
||||
by_path.setdefault(path, []).append(number)
|
||||
parts = []
|
||||
for path, numbers in by_path.items():
|
||||
ranges: list[list[int]] = []
|
||||
for number in sorted(set(numbers)):
|
||||
if ranges and number == ranges[-1][1] + 1:
|
||||
ranges[-1][1] = number
|
||||
else:
|
||||
ranges.append([number, number])
|
||||
spans = ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in ranges)
|
||||
parts.append(f"{path}:{spans}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def package_record(scripts: Scripts, package: Module) -> Launcher:
|
||||
"""What the package builds and the systems it registers."""
|
||||
systems: list[str] = []
|
||||
lines: list[tuple[str, int]] = [(package.path, package.id_line)]
|
||||
if package.repo_line is not None:
|
||||
lines.append((package.path, package.repo_line))
|
||||
for event in scripts.walk(package):
|
||||
if event.heredoc or not event.words or not event.words[0]:
|
||||
continue
|
||||
command = event.words[0][0]
|
||||
if command == "addSystem":
|
||||
values = event.words[1] if len(event.words) > 1 else []
|
||||
elif command == "addPort":
|
||||
values = ["ports"]
|
||||
else:
|
||||
continue
|
||||
if not values:
|
||||
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"{command} takes no value")
|
||||
for value in values:
|
||||
if "$" in value or not value:
|
||||
raise LauncherPatternMissing(
|
||||
f"{event.path}:{event.line}", f"system name {value!r} not resolved"
|
||||
)
|
||||
if value not in systems:
|
||||
systems.append(value)
|
||||
lines.append((event.path, event.line))
|
||||
return Launcher(
|
||||
emulator=package.id, package=package.id, repo=package.repo,
|
||||
systems=systems or None, build=package.build, kind="package", names=[],
|
||||
source_ref=source_ref(lines),
|
||||
)
|
||||
|
||||
|
||||
def launch_script_records(scripts: Scripts) -> list[Launcher]:
|
||||
"""The launch scripts installed next to an emulator that refuse to start without a file."""
|
||||
records = []
|
||||
for path, text in sorted(scripts.files.items()):
|
||||
parts = PurePosixPath(path).parts
|
||||
is_script = (
|
||||
len(parts) == 4 and parts[0] == SCRIPTMODULES and parts[1] in SECTIONS
|
||||
and path.endswith(".sh")
|
||||
)
|
||||
if not is_script or not _mentions_bios(text):
|
||||
continue
|
||||
package = scripts.modules.get(parts[2])
|
||||
if package is None:
|
||||
raise LauncherPatternMissing(path, f"launch script of no package {parts[2]}")
|
||||
records += _launch_script(path, text.splitlines(), package)
|
||||
return records
|
||||
|
||||
|
||||
def _launch_script(path: str, lines: list[str], package: Module) -> list[Launcher]:
|
||||
env: dict[str, str] = {"HOME": "$HOME"}
|
||||
records = []
|
||||
checks = 0
|
||||
number = 0
|
||||
while number < len(lines):
|
||||
number += 1
|
||||
stripped = lines[number - 1].strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
assign = re.match(r'^([A-Za-z_]\w*)="([^"]*)"$', stripped)
|
||||
if assign is not None:
|
||||
env[assign.group(1)] = _VAR.sub(
|
||||
lambda m: env.get(m.group(1) or m.group(3), m.group(0)), assign.group(2)
|
||||
)
|
||||
continue
|
||||
check = re.match(r'^if \[\[ ! -f "([^"]+)" \]\]; then$', stripped)
|
||||
if check is None:
|
||||
continue
|
||||
tested = check.group(1)
|
||||
resolved = _VAR.sub(lambda m: env.get(m.group(1) or m.group(3), m.group(0)), tested)
|
||||
if not _mentions_bios(resolved):
|
||||
continue
|
||||
block_start = number
|
||||
message: str | None = None
|
||||
exits = False
|
||||
while number < len(lines) and lines[number].strip() != "fi":
|
||||
number += 1
|
||||
body = lines[number - 1].strip()
|
||||
exits = exits or body == "exit 1"
|
||||
pause = re.search(r'--pause "([^"]*)"', body)
|
||||
if pause is not None:
|
||||
message = pause.group(1)
|
||||
number += 1
|
||||
if not exits:
|
||||
continue
|
||||
checks += 1
|
||||
variable = re.fullmatch(r"\$\{?(\w+)\}?", tested)
|
||||
passes_to = None
|
||||
cited = list(range(block_start, number + 1))
|
||||
if variable is not None:
|
||||
for later, line in enumerate(lines[number:], number + 1):
|
||||
flag = re.search(r'(--?[\w-]+)="\$' + variable.group(1) + r'"', line)
|
||||
if flag is not None:
|
||||
passes_to = f"command line {flag.group(1)}"
|
||||
cited.append(later)
|
||||
break
|
||||
folder, name = _split_path(resolved)
|
||||
records.append(Launcher(
|
||||
emulator=package.id, package=package.id, build=package.build,
|
||||
kind="fixed_name", names=[name], case_insensitive=False,
|
||||
search_dir=folder, passes_to=passes_to,
|
||||
fails_when_missing=message if message is not None else True,
|
||||
source_ref=source_ref([(path, n) for n in cited]),
|
||||
))
|
||||
if not checks:
|
||||
raise LauncherPatternMissing(path, "names the BIOS directory but checks no file")
|
||||
return records
|
||||
|
||||
|
||||
def extract_launchers(files: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""Every launcher record, from the scriptmodules keyed by repo-relative path."""
|
||||
scripts = Scripts(files)
|
||||
records: list[Launcher] = []
|
||||
for package in sorted(scripts.modules.values(), key=lambda m: m.path):
|
||||
records.append(package_record(scripts, package))
|
||||
records += merge_records(PackageReader(scripts, package).read())
|
||||
records += launch_script_records(scripts)
|
||||
return [record.entry() for record in records]
|
||||
@@ -15,6 +15,10 @@ RetroArch: RetroPie claimed every libretro core in existence while shipping
|
||||
96 of them, and claimed none of the standalone emulators it also packages
|
||||
(openmsx, pcsx2, xroar, sdltrs, amiberry and the rest). Both halves were
|
||||
wrong, in opposite directions.
|
||||
|
||||
The same scripts carry the launcher layer (retropie_launchers): where each
|
||||
package links, copies or passes a BIOS file, what it builds and which
|
||||
systems it registers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,8 +31,14 @@ import urllib.request
|
||||
|
||||
try:
|
||||
from .base_scraper import BaseScraper, BiosRequirement, fetch_branch_head
|
||||
from .retropie_launchers import SCRIPTMODULES
|
||||
from .retropie_launchers import SECTIONS as _SCRIPT_SECTIONS
|
||||
from .retropie_launchers import extract_launchers
|
||||
except ImportError:
|
||||
from base_scraper import BaseScraper, BiosRequirement, fetch_branch_head
|
||||
from retropie_launchers import SCRIPTMODULES
|
||||
from retropie_launchers import SECTIONS as _SCRIPT_SECTIONS
|
||||
from retropie_launchers import extract_launchers
|
||||
|
||||
PLATFORM_NAME = "retropie"
|
||||
|
||||
@@ -43,7 +53,7 @@ _VERSION_FILE = "retropie_packages.sh"
|
||||
_VERSION = re.compile(r'^__version="([^"]+)"', re.MULTILINE)
|
||||
# Sections RetroPie does not build as emulators: setup helpers, themes,
|
||||
# drivers and the like carry no core.
|
||||
_PACKAGE_DIRS = ("emulators", "libretrocores", "ports")
|
||||
_PACKAGE_DIRS = _SCRIPT_SECTIONS
|
||||
|
||||
|
||||
class Scraper(BaseScraper):
|
||||
@@ -56,6 +66,8 @@ class Scraper(BaseScraper):
|
||||
super().__init__(url=url)
|
||||
self._modules: list[str] | None = None
|
||||
self._version = ""
|
||||
# Package scripts and the launch scripts beside them, by repo path.
|
||||
self._scripts: dict[str, str] = {}
|
||||
|
||||
def _fetch_archive(self) -> bytes:
|
||||
request = urllib.request.Request(
|
||||
@@ -89,12 +101,19 @@ class Scraper(BaseScraper):
|
||||
and parts[0] == "scriptmodules"
|
||||
and parts[1] in _PACKAGE_DIRS
|
||||
)
|
||||
if not (is_version_file or is_package):
|
||||
is_launch_script = (
|
||||
len(parts) == 4 and parts[0] == SCRIPTMODULES and parts[1] in _SCRIPT_SECTIONS
|
||||
)
|
||||
if not (is_version_file or is_package or is_launch_script):
|
||||
continue
|
||||
handle = archive.extractfile(member)
|
||||
if handle is None:
|
||||
continue
|
||||
text = handle.read().decode("utf-8", errors="replace")
|
||||
if is_package or is_launch_script:
|
||||
self._scripts[relative] = text
|
||||
if is_launch_script:
|
||||
continue
|
||||
match = (_VERSION if is_version_file else _MODULE_ID).search(text)
|
||||
if not match:
|
||||
continue
|
||||
@@ -139,6 +158,7 @@ class Scraper(BaseScraper):
|
||||
"source": self.url,
|
||||
"base_destination": "BIOS",
|
||||
"cores": cores,
|
||||
"launchers": extract_launchers(self._scripts),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user