mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
chore: merge the per-run probe lock
This commit is contained in:
commit
d7e17491f0
796 files changed
+15124
-1859
No files matched your search
+4
-3
@@ -303,9 +303,9 @@ def child_env(romfetch: bool = False) -> dict[str, str]:
|
||||
"""What a program the campaign starts inherits: autorun's allowlist,
|
||||
never a token or a password. romfetch also gets its own settings
|
||||
(ROMFETCH_*), its account staying in its config file."""
|
||||
names: list[str] = []
|
||||
names: list[str] = [core_probe.PROBE_LOCK_ENV, core_probe.PROBE_LOCK_HELD_ENV]
|
||||
if romfetch:
|
||||
names = [k for k in os.environ if k.startswith("ROMFETCH_")] + list(ROMFETCH_PASSED)
|
||||
names += [k for k in os.environ if k.startswith("ROMFETCH_")] + list(ROMFETCH_PASSED)
|
||||
return autorun.safe_env(names)
|
||||
|
||||
|
||||
@@ -1413,7 +1413,8 @@ class Campaign:
|
||||
key = self.finding_key(emulator, content, entry, verdict)
|
||||
previous = self.latest_findings().get(key)
|
||||
if previous and previous["status"] == status and previous["rule"] == verdict.rule \
|
||||
and previous["run_shows"] == verdict.value and previous["profile_says"] == says:
|
||||
and previous["run_shows"] == verdict.value and previous["profile_says"] == says \
|
||||
and previous["evidence"] == list(verdict.evidence):
|
||||
return
|
||||
line = mr.seal_line({
|
||||
"schema_version": 1, "found_at": now_iso(), "key": key, "emulator": emulator,
|
||||
|
||||
@@ -214,12 +214,14 @@ def rule_validation(zeros: Run | None, half: Run | None, none: Run, present: Run
|
||||
if size is False and "size" in declared:
|
||||
problems.append(f"the first half of {name} is run, the profile declares a size check")
|
||||
statement = f"the core {' and '.join(shown)} of {name}"
|
||||
# What the runs show, for a finding beside what the profile says.
|
||||
checks = {"content": content, "size": size}
|
||||
if problems:
|
||||
return Verdict("validation_contradicted", "validation", said,
|
||||
return Verdict("validation_contradicted", "validation", checks,
|
||||
f"{statement}: {'; '.join(problems)}", evidence)
|
||||
if missing_size:
|
||||
if not editable:
|
||||
return Verdict("validation_contradicted", "validation", said,
|
||||
return Verdict("validation_contradicted", "validation", checks,
|
||||
f"{statement}: the per-build validation has no size check", evidence)
|
||||
value = [*(entry.get("validation") or []), "size"]
|
||||
return Verdict("validation_size_added", "validation", value,
|
||||
|
||||
@@ -235,6 +235,10 @@ def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[
|
||||
return config, [platform_name]
|
||||
|
||||
merged, chain = _inherited_yaml(config["inherits"], platforms_dir)
|
||||
# A version names one platform's release, never its parent's: Lakka
|
||||
# read as RetroArch v1.22.2 in its manifest, pack name and site page.
|
||||
# dat_version stays, it is the version of the inherited list itself.
|
||||
merged.pop("version", None)
|
||||
merged.update(
|
||||
{k: v for k, v in config.items() if k not in ("inherits", "overrides")}
|
||||
)
|
||||
|
||||
+69
-4
@@ -21,6 +21,7 @@ import argparse
|
||||
import contextlib
|
||||
import ctypes
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -35,7 +36,7 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zlib
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -73,6 +74,39 @@ PRINTF_SPEC = re.compile(r"%(?P<flags>[-+ #0]*)(?P<width>\*|\d+)?(?:\.(?P<prec>\
|
||||
r"(?P<len>hh|h|ll|l|j|z|t|L)?(?P<conv>[diouxXeEfFgGaAcspn%])")
|
||||
|
||||
|
||||
# One measurement at a time on the host: each core_probe execution takes this
|
||||
# lock itself, its load check included, and releases it when it ends. A pass
|
||||
# of many runs never holds it between them, so two passes interleave.
|
||||
PROBE_LOCK_ENV = "RETROBIOS_PROBE_LOCK"
|
||||
PROBE_LOCK_HELD_ENV = "RETROBIOS_PROBE_LOCK_HELD"
|
||||
DEFAULT_PROBE_LOCK = Path.home() / ".local" / "state" / "retrobios" / "probe.lock"
|
||||
|
||||
|
||||
def probe_lock_path() -> Path:
|
||||
return Path(os.environ.get(PROBE_LOCK_ENV) or DEFAULT_PROBE_LOCK)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def host_probe_lock() -> Iterator[None]:
|
||||
"""The host's probe lock for one execution. A caller that already holds
|
||||
it for this run says so with RETROBIOS_PROBE_LOCK_HELD."""
|
||||
if os.environ.get(PROBE_LOCK_HELD_ENV):
|
||||
yield
|
||||
return
|
||||
path = probe_lock_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as handle:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print(f"waiting for {path}: another measurement is running", file=sys.stderr)
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
"""A run that cannot produce a trustworthy result."""
|
||||
|
||||
@@ -732,6 +766,30 @@ def _bwrap_base(bwrap: str, network: bool) -> list[str]:
|
||||
return argv
|
||||
|
||||
|
||||
def interpreter_paths() -> list[Path]:
|
||||
"""The trees the worker's Python lives in, when the sandbox's system
|
||||
binds do not hold them: CI's setup-python under /opt/hostedtoolcache, a
|
||||
venv, pyenv or uv. Each link on the way to the interpreter is a path too:
|
||||
a venv's python points at uv's unversioned name, itself a link to the
|
||||
versioned tree. Without them the worker could not start in the sandbox,
|
||||
and every run crashed."""
|
||||
system = ("/usr/", "/bin/", "/lib")
|
||||
found: list[Path] = []
|
||||
candidates = [Path(sys.prefix), Path(sys.base_prefix)]
|
||||
hop = Path(sys.executable)
|
||||
for _ in range(16):
|
||||
candidates.append(hop.parent.parent)
|
||||
if not hop.is_symlink():
|
||||
break
|
||||
target = Path(os.readlink(hop))
|
||||
hop = target if target.is_absolute() else hop.parent / target
|
||||
for path in candidates:
|
||||
path = Path(os.path.abspath(path))
|
||||
if not str(path).startswith(system) and path not in found and path.exists():
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (),
|
||||
network: bool = False) -> tuple[list[str], str]:
|
||||
"""The bubblewrap prefix a third-party program runs under, and its name.
|
||||
@@ -754,8 +812,10 @@ def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (),
|
||||
return [], "environment only: bwrap cannot create a sandbox here"
|
||||
argv = _bwrap_base(bwrap, network)
|
||||
for path in readonly:
|
||||
resolved = Path(path).resolve()
|
||||
argv += ["--ro-bind", str(resolved), str(resolved)]
|
||||
# Mounted at the path as the caller names it: a link's own path stays
|
||||
# usable inside, its target's bytes behind it.
|
||||
resolved, named = Path(path).resolve(), Path(os.path.abspath(path))
|
||||
argv += ["--ro-bind", str(resolved), str(named)]
|
||||
for path in writable:
|
||||
resolved = Path(path).resolve()
|
||||
resolved.mkdir(parents=True, exist_ok=True)
|
||||
@@ -846,7 +906,7 @@ def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_ou
|
||||
if strace:
|
||||
command = [strace, "-f", "-qq", "-s", "4096", "-e", "trace=openat,open", "-o", str(trace_path)] + command
|
||||
writable = [scratch] + [p for p in (png_dir, state_out.parent if state_out else None) if p]
|
||||
readonly = [Path(__file__), Path(args.core), system_dir]
|
||||
readonly = [Path(__file__), Path(args.core), system_dir, *interpreter_paths()]
|
||||
if args.content:
|
||||
readonly.append(Path(args.content).resolve().parent)
|
||||
readonly += [Path(value) for _, value in args.env if Path(value).is_absolute() and Path(value).exists()]
|
||||
@@ -1355,6 +1415,11 @@ def probe(args: argparse.Namespace) -> int:
|
||||
for problem in problems:
|
||||
print(f"ERROR: {problem}", file=sys.stderr)
|
||||
return 2
|
||||
with host_probe_lock():
|
||||
return _probe(args)
|
||||
|
||||
|
||||
def _probe(args: argparse.Namespace) -> int:
|
||||
max_load = args.max_load if args.max_load is not None else (os.cpu_count() or 1) / 4
|
||||
load_before = load_average()
|
||||
if args.record and load_before and load_before[0] > max_load:
|
||||
|
||||
@@ -35,6 +35,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
**dict.fromkeys((
|
||||
"installer", "os", "os_name", "stage", "reason", "platform", "message",
|
||||
"folder", "dest", "system", "name", "bios_dir", "path", "member",
|
||||
"list_version", "installed_version",
|
||||
), _TEXT),
|
||||
"mode": {"enum": ["install", "check", "dry-run", "list"]},
|
||||
"state": {"enum": ["present", "wrong_hash", "missing"]},
|
||||
|
||||
@@ -22,7 +22,7 @@ import ast
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PAGE = ROOT / "wiki" / "installer.md"
|
||||
@@ -154,6 +154,40 @@ def route_keys() -> str:
|
||||
return _table(heading, rows)
|
||||
|
||||
|
||||
def _marker(rule: _install._OsRule) -> str:
|
||||
"""A card rule's evidence in words: the file or folder, and the line it
|
||||
holds for a rule that reads one."""
|
||||
if rule.method == "file_matches":
|
||||
path, pattern = rule.values
|
||||
line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "")
|
||||
return f"`{path}` holding the line `{line}`"
|
||||
return ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`"
|
||||
for value in rule.values)
|
||||
|
||||
|
||||
def _list_words(plat: str) -> str:
|
||||
name = _install.display_name(plat)
|
||||
return f"{name}'{'' if name.endswith('s') else 's'} list"
|
||||
|
||||
|
||||
def card_marker_rows() -> list[list[str]]:
|
||||
"""The files that name a card or a SHARE drive, in the order they are
|
||||
read: install.py's card rules, then its SHARE drive files."""
|
||||
rows = [[_marker(rule), rule.name, f"`{rule.bios}`, {_list_words(rule.manifest)}"]
|
||||
for rule in _install._card_rules()]
|
||||
hostname = "system.hostname=BATOCERA"
|
||||
for plat, marker in _install._SHARE_DRIVES:
|
||||
name = _install.display_name(plat)
|
||||
evidence = (f"`{marker}` holding `{hostname}` or no hostname line, and no KNULLI file "
|
||||
"above" if plat == "batocera" else f"`{marker}`")
|
||||
rows.append([evidence, f"{name} SHARE drive", f"`bios`, {_list_words(plat)}"])
|
||||
rows.append([f"`{_install._SHARE_DRIVES[0][1]}` with any other hostname, and no KNULLI "
|
||||
"file above, or the files of two SHARE drives",
|
||||
"named by neither: the run says which systems it could be",
|
||||
"the folder of the system named with `--platform`"])
|
||||
return rows
|
||||
|
||||
|
||||
def render(page: str) -> str:
|
||||
"""The page with each marked table written from install.py."""
|
||||
writers = {
|
||||
@@ -162,6 +196,8 @@ def render(page: str) -> str:
|
||||
"events": lambda body: _table(["Kind", "Keys", "When"], event_rows(body)),
|
||||
"pad-routes": lambda body: _table(["Value", "Input", "Set by"], pad_rows(body)),
|
||||
"route-keys": lambda _body: route_keys(),
|
||||
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
|
||||
card_marker_rows()),
|
||||
}
|
||||
seen = []
|
||||
|
||||
|
||||
@@ -351,6 +351,23 @@ def _platform_pack_stem(
|
||||
return "_".join(_name_part(n) for n in names) + version_tag
|
||||
|
||||
|
||||
def platform_list_version(config: dict, platform: str) -> str:
|
||||
"""The version of the list a platform's YAML transcribes: the release
|
||||
tag the scraper read, or for a list kept on a branch with no release,
|
||||
the date of the commit it pinned. load_platform_config never passes a
|
||||
parent's version to a child, Lakka is not RetroArch v1.22.2.
|
||||
|
||||
The installer compares it with the version a machine runs, so a missing
|
||||
value is refused rather than written as a stand-in.
|
||||
"""
|
||||
version = str(config.get("version") or "").strip()
|
||||
if not version:
|
||||
raise ValueError(
|
||||
f"{platform}: the platform YAML records no version of its own"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
# What the builder writes into a platform pack beside the files it carries.
|
||||
PACK_DOCUMENTS = ("README.txt", "manifest.json")
|
||||
|
||||
@@ -2568,6 +2585,9 @@ def _run_manifest_mode(
|
||||
alias_manifest["display_name"] = alias_cfg.get(
|
||||
"platform", alias_plat
|
||||
)
|
||||
alias_manifest["version"] = platform_list_version(
|
||||
alias_cfg, alias_plat
|
||||
)
|
||||
alias_manifest["base_destination"] = alias_cfg.get(
|
||||
"base_destination", ""
|
||||
)
|
||||
@@ -4600,7 +4620,7 @@ def generate_manifest(
|
||||
**({"no_game_data": True} if no_game_data else {}),
|
||||
"platform": platform_name,
|
||||
"display_name": platform_display,
|
||||
"version": "1.0",
|
||||
"version": platform_list_version(config, platform_name),
|
||||
"generated": now,
|
||||
"base_destination": base_dest,
|
||||
"detect": detect,
|
||||
@@ -4759,12 +4779,31 @@ def pack_systems(install_manifest: dict) -> dict:
|
||||
("the file list comes from an older release") and offered no Choose
|
||||
systems, the route of a handheld without Wi-Fi.
|
||||
"""
|
||||
return {
|
||||
files = install_manifest.get("files", [])
|
||||
entries = [*files, *install_manifest.get("omitted_files", [])]
|
||||
# What the install list narrows by, the installer's recorded_axes: with
|
||||
# the pack alone it names that list as the way to --core and --region
|
||||
# only when the list records them. It named install/misterfpga.json for
|
||||
# --core, a list that records no core.
|
||||
records = {"systems"} if any("systems" in entry for entry in entries) else set()
|
||||
if any(copy.get("cores") for entry in files
|
||||
for copy in [entry, *entry.get("alternatives", []),
|
||||
*entry.get("slot_choices", [])]):
|
||||
records.add("cores")
|
||||
if records or any("regions" in entry for entry in entries):
|
||||
records.add("regions")
|
||||
systems = {
|
||||
"files": {entry["dest"]: list(entry.get("systems") or [])
|
||||
for entry in install_manifest.get("files", []) if "systems" in entry},
|
||||
for entry in files if "systems" in entry},
|
||||
"system_titles": dict(install_manifest.get("system_titles") or {}),
|
||||
"system_names": dict(install_manifest.get("system_names") or {}),
|
||||
"list_records": sorted(records),
|
||||
}
|
||||
# The version of the platform's list, which the installer compares with
|
||||
# the one the machine runs when it has the pack alone.
|
||||
if install_manifest.get("version"):
|
||||
systems["list_version"] = install_manifest["version"]
|
||||
return systems
|
||||
|
||||
|
||||
def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
|
||||
@@ -4779,6 +4818,9 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
|
||||
for key in ("system_titles", "system_names"):
|
||||
if systems.get(key):
|
||||
manifest[key] = systems[key]
|
||||
for key in ("list_records", "list_version"):
|
||||
if key in systems:
|
||||
manifest[key] = systems[key]
|
||||
|
||||
|
||||
def inject_manifest(zip_path: str, manifest: dict) -> None:
|
||||
|
||||
+932
-207
File diff suppressed because it is too large.
Load diff
+253
-3
@@ -31,6 +31,7 @@ import yaml
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import upstream
|
||||
import version_basis
|
||||
from common import (
|
||||
_norm_system_id,
|
||||
github_token,
|
||||
@@ -2670,6 +2671,129 @@ def apply_edit(path: Path, new_text: str, expected: dict) -> None:
|
||||
raise YamlWriteError(f"{path}: structure changed beyond the intended field")
|
||||
|
||||
|
||||
# Key order of a written version_basis, as a reader expects it.
|
||||
_BASIS_ORDER = ("kind", "why", "repo", "url", "commit", "path", "pattern", "format",
|
||||
"match", "tag", "distance", "of", "artifact")
|
||||
|
||||
|
||||
def _top_block(lines: list[str], key: str) -> tuple[int, int] | None:
|
||||
"""Line span of a top-level key and the indented lines under it."""
|
||||
indices = _top_level_indices(lines, key)
|
||||
if not indices:
|
||||
return None
|
||||
if len(indices) > 1:
|
||||
raise YamlWriteError(f"{key}: expected one top-level line, found {len(indices)}")
|
||||
end = indices[0] + 1
|
||||
while end < len(lines) and (lines[end].startswith((" ", "\t")) or not lines[end].strip()):
|
||||
end += 1
|
||||
# Blank lines closing the block belong to the document, not to it.
|
||||
while end > indices[0] + 1 and not lines[end - 1].strip():
|
||||
end -= 1
|
||||
return indices[0], end
|
||||
|
||||
|
||||
def _render_version(value: object) -> list[str]:
|
||||
if isinstance(value, dict):
|
||||
return ["core_version:"] + [
|
||||
f" {mode}: {'null' if v is None else _double_quoted(str(v))}"
|
||||
for mode, v in value.items()
|
||||
]
|
||||
return [f"core_version: {'null' if value is None else _double_quoted(str(value))}"]
|
||||
|
||||
|
||||
def _render_basis(basis: dict) -> list[str]:
|
||||
def ordered(entry: dict) -> dict:
|
||||
rank = {key: i for i, key in enumerate(_BASIS_ORDER)}
|
||||
return dict(sorted(entry.items(), key=lambda item: rank.get(item[0], len(rank))))
|
||||
|
||||
def scalar(value: object) -> str:
|
||||
# Every string quoted: a tag 2407 or 0.10 read back as a number. A
|
||||
# pattern keeps its backslashes readable in single quotes.
|
||||
if isinstance(value, bool) or not isinstance(value, str):
|
||||
return str(value).lower() if isinstance(value, bool) else str(value)
|
||||
if "\\" in value or '"' in value:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
return _double_quoted(value)
|
||||
|
||||
def block(entry: dict, indent: str) -> list[str]:
|
||||
# kind, why and repo are words of the schema's enums, written bare.
|
||||
return [f"{indent}{key}: {value if key in ('kind', 'why', 'repo') else scalar(value)}"
|
||||
for key, value in ordered(entry).items()]
|
||||
|
||||
if "kind" in basis:
|
||||
return ["version_basis:", *block(basis, " ")]
|
||||
lines = ["version_basis:"]
|
||||
for mode, entry in basis.items():
|
||||
lines += [f" {mode}:", *block(entry, " ")]
|
||||
return lines
|
||||
|
||||
|
||||
def set_version_fields(text: str, version: object, basis: dict | None) -> str:
|
||||
"""Rewrite core_version and version_basis, version_basis right after it."""
|
||||
lines = text.splitlines()
|
||||
span = _top_block(lines, "core_version")
|
||||
rendered = _render_version(version)
|
||||
if span is None:
|
||||
anchor = _top_block(lines, "upstream_commit") or _top_block(lines, "source_commit") \
|
||||
or _top_block(lines, "profiled_date") or _top_block(lines, "upstream") \
|
||||
or _top_block(lines, "source") or _top_block(lines, "alias_of") \
|
||||
or _top_block(lines, "type")
|
||||
if anchor is None:
|
||||
raise YamlWriteError("core_version: no line to write it after")
|
||||
lines[anchor[1]:anchor[1]] = rendered
|
||||
else:
|
||||
lines[span[0]:span[1]] = rendered
|
||||
if basis is not None:
|
||||
old = _top_block(lines, "version_basis")
|
||||
if old is not None:
|
||||
del lines[old[0]:old[1]]
|
||||
after = _top_block(lines, "core_version")
|
||||
lines[after[1]:after[1]] = _render_basis(basis)
|
||||
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
||||
|
||||
|
||||
def apply_readings(document: dict, readings: list) -> tuple[object, dict | None]:
|
||||
"""core_version and version_basis as the readings state them.
|
||||
|
||||
A reading that could not be made leaves its build mode as written.
|
||||
"""
|
||||
version = document.get("core_version")
|
||||
basis = document.get("version_basis")
|
||||
basis = dict(basis) if isinstance(basis, dict) else None
|
||||
if isinstance(version, dict):
|
||||
version = dict(version)
|
||||
for reading in readings:
|
||||
if reading.status not in (version_basis.MATCH, version_basis.DRIFT):
|
||||
continue
|
||||
if reading.mode:
|
||||
if not isinstance(version, dict):
|
||||
version = {}
|
||||
version[reading.mode] = reading.read
|
||||
else:
|
||||
version = reading.read
|
||||
if reading.updates and basis is not None:
|
||||
target = basis if not reading.mode else dict(basis.get(reading.mode) or {})
|
||||
target.update(dict(reading.updates))
|
||||
if reading.mode:
|
||||
basis[reading.mode] = target
|
||||
return version, basis
|
||||
|
||||
|
||||
def write_version(path: Path, readings: list) -> bool:
|
||||
"""Set core_version (and a tag basis' tag and distance) from the readings."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
document = yaml.safe_load(text)
|
||||
version, basis = apply_readings(document, readings)
|
||||
if version == document.get("core_version") and basis == document.get("version_basis"):
|
||||
return False
|
||||
expected = dict(document)
|
||||
expected["core_version"] = version
|
||||
if basis is not None:
|
||||
expected["version_basis"] = basis
|
||||
apply_edit(path, set_version_fields(text, version, basis), expected)
|
||||
return True
|
||||
|
||||
|
||||
def backfill_commit(path: Path, sha: str) -> bool:
|
||||
"""Insert source_commit after profiled_date. False when already present."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -3313,9 +3437,15 @@ def _needs_review(report: ProfileReport, accept_changed: bool) -> bool:
|
||||
|
||||
|
||||
def bump_commit(
|
||||
path: Path, report: ProfileReport, accept_changed: bool = False
|
||||
path: Path, report: ProfileReport, accept_changed: bool = False,
|
||||
cache_dir: str = DEFAULT_CACHE, offline: bool = False,
|
||||
) -> bool:
|
||||
"""Advance source_commit to HEAD when nothing needs a read again."""
|
||||
"""Advance source_commit to HEAD when nothing needs a read again.
|
||||
|
||||
core_version moves with the pin, read again from version_basis at the
|
||||
new revision. Four bulk advances moved 295 pins and no version: an
|
||||
advance whose version cannot be read is refused.
|
||||
"""
|
||||
if _needs_review(report, accept_changed):
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -3375,10 +3505,33 @@ def bump_commit(
|
||||
new_text = _set_pin(
|
||||
new_text, document, "upstream_commit", report.upstream_head, "source_commit"
|
||||
)
|
||||
readings = version_basis.check(expected, cache_dir, offline, recompute=True)
|
||||
refused = version_refusal(readings)
|
||||
if refused:
|
||||
print(f"{path.stem}: {refused}, its pin is not advanced", file=sys.stderr)
|
||||
return False
|
||||
version, basis = apply_readings(expected, readings)
|
||||
if version != expected.get("core_version") or basis != expected.get("version_basis"):
|
||||
new_text = set_version_fields(new_text, version, basis)
|
||||
expected["core_version"] = version
|
||||
if basis is not None:
|
||||
expected["version_basis"] = basis
|
||||
apply_edit(path, new_text, expected)
|
||||
return True
|
||||
|
||||
|
||||
def version_refusal(readings: list) -> str | None:
|
||||
"""Why the version at a new pin cannot be stated, or None."""
|
||||
for reading in readings:
|
||||
if reading.status == version_basis.NO_BASIS:
|
||||
return "no version_basis says where its version is read"
|
||||
if reading.status == version_basis.UNREADABLE:
|
||||
return f"core_version cannot be read at the new pin: {reading.where}"
|
||||
if reading.status == version_basis.UNCHECKED:
|
||||
return f"core_version is {reading.where}, not read from the code at a pin"
|
||||
return None
|
||||
|
||||
|
||||
def pins_changed(before: dict, after: dict) -> list[str]:
|
||||
"""The pin fields whose value a write changed."""
|
||||
return [
|
||||
@@ -3996,6 +4149,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--check-version", action="store_true",
|
||||
help="compare core_version with the latest tag and release",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify-version", action="store_true",
|
||||
help="read core_version again from version_basis at the pin and "
|
||||
"exit 1 when the code states another",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--set-version", action="store_true",
|
||||
help="write core_version from version_basis at the pin, the nearest "
|
||||
"tag asked of git again",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect-new-files", action="store_true",
|
||||
help="name files upstream loads that the profile does not list",
|
||||
@@ -4284,7 +4447,8 @@ def _apply_writes(
|
||||
applied = []
|
||||
if args.rebase_refs and not report.skipped:
|
||||
applied = rebase_refs(target, report, args.accept_changed)
|
||||
moved = bump_commit(target, report, args.accept_changed)
|
||||
moved = bump_commit(target, report, args.accept_changed,
|
||||
args.cache_dir, args.offline)
|
||||
if args.rebase_refs and applied and not moved:
|
||||
keyed = any(isinstance(profile.get(f), dict) for f in ("source_commit", "upstream_commit"))
|
||||
reason = (
|
||||
@@ -4723,6 +4887,32 @@ def _print_triage(
|
||||
|
||||
def _refuse_flag_combinations(args: argparse.Namespace) -> None:
|
||||
"""Exit on a flag the chosen mode would not apply, before any request."""
|
||||
if args.verify_version or args.set_version:
|
||||
ignored = [
|
||||
flag for flag, on in (
|
||||
("--verify-version", args.verify_version and args.set_version),
|
||||
("--markdown", args.markdown),
|
||||
("--fetch-plan", args.fetch_plan),
|
||||
("--triage", args.triage),
|
||||
("--strict", args.strict),
|
||||
("--check-version", args.check_version),
|
||||
("--detect-new-files", args.detect_new_files),
|
||||
("--watch-hashes", args.watch_hashes),
|
||||
("--full-diff", args.full_diff),
|
||||
("--tree-diff", args.tree_diff),
|
||||
("--backfill-commits", args.backfill_commits),
|
||||
("--rebase-refs", args.rebase_refs),
|
||||
("--attribute-refs", args.attribute_refs),
|
||||
("--bump-commit", args.bump_commit),
|
||||
("--realign-prose", args.realign_prose),
|
||||
("--accept-changed", args.accept_changed),
|
||||
("--dry-run", args.dry_run and not args.set_version),
|
||||
) if on
|
||||
]
|
||||
if ignored:
|
||||
mode = "--set-version" if args.set_version else "--verify-version"
|
||||
print(f"{mode} does not apply {', '.join(ignored)}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if args.fresh and args.offline:
|
||||
print("--fresh asks the forges again, which --offline forbids.", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -4809,6 +4999,63 @@ def _refuse_flag_combinations(args: argparse.Namespace) -> None:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _version_selection(args: argparse.Namespace, profiles: dict) -> dict:
|
||||
"""--all reaches the alias and test profiles too: each states a version."""
|
||||
if args.all and not (args.system or args.type_ or args.stale_before or args.limit):
|
||||
return dict(sorted(profiles.items()))
|
||||
return select_profiles(profiles, args)
|
||||
|
||||
|
||||
def format_reading(name: str, reading: version_basis.Reading) -> str:
|
||||
mode = f"[{reading.mode}] " if reading.mode else ""
|
||||
kind = f"{reading.kind} " if reading.kind else ""
|
||||
if reading.status == version_basis.DRIFT:
|
||||
said = f"declared {reading.declared}, code {reading.read}"
|
||||
elif reading.status == version_basis.MATCH:
|
||||
said = str(reading.read)
|
||||
else:
|
||||
said = f"declared {reading.declared}"
|
||||
return f"{name}: {mode}{reading.status} {said} ({kind}{reading.where})"
|
||||
|
||||
|
||||
def run_versions(args: argparse.Namespace, profiles: dict) -> None:
|
||||
"""--verify-version and --set-version: core_version against the code."""
|
||||
selected = _version_selection(args, profiles)
|
||||
if args.set_version and not args.dry_run and not args.force:
|
||||
_refuse_dirty_tree(args.emulators_dir, list(selected))
|
||||
counts: Counter = Counter()
|
||||
rows = []
|
||||
failed = False
|
||||
for name, profile in selected.items():
|
||||
readings = version_basis.check(
|
||||
profile, args.cache_dir, args.offline, profiles, recompute=args.set_version
|
||||
)
|
||||
for reading in readings:
|
||||
counts[reading.status] += 1
|
||||
rows.append({"profile": name, **asdict(reading)})
|
||||
if not args.as_json and (not args.changed_only or reading.status != version_basis.MATCH):
|
||||
print(format_reading(name, reading))
|
||||
if args.set_version:
|
||||
path = Path(args.emulators_dir) / f"{name}.yml"
|
||||
if args.dry_run:
|
||||
continue
|
||||
try:
|
||||
if write_version(path, readings):
|
||||
print(f"{name}: core_version written")
|
||||
except YamlWriteError as exc:
|
||||
print(f"{name}: write refused: {exc}", file=sys.stderr)
|
||||
failed = True
|
||||
elif any(r.status in version_basis.FAILING for r in readings):
|
||||
failed = True
|
||||
if args.as_json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
else:
|
||||
print(", ".join(f"{count} {status}" for status, count in sorted(counts.items())),
|
||||
file=sys.stderr)
|
||||
if failed:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Build one report per selected profile, then apply writes and output."""
|
||||
args = build_parser().parse_args()
|
||||
@@ -4818,6 +5065,9 @@ def main() -> None:
|
||||
# trzy/Supermodel and VICE read as behind their real HEAD.
|
||||
upstream.MOVING_TTL = 0
|
||||
profiles = load_emulator_profiles(args.emulators_dir, skip_aliases=False)
|
||||
if args.verify_version or args.set_version:
|
||||
run_versions(args, profiles)
|
||||
return
|
||||
selected = select_profiles(profiles, args)
|
||||
_check_quota(len(selected), args.offline)
|
||||
|
||||
|
||||
@@ -158,7 +158,12 @@ def merge_fbneo_profile(
|
||||
profile = _load_yaml(profile_path)
|
||||
hashes = _load_json(hashes_path)
|
||||
|
||||
profile["core_version"] = hashes.get("version", profile.get("core_version"))
|
||||
# core_version stays as written: it is the version of the code at the
|
||||
# profile's own pin, read through version_basis (profile_sync
|
||||
# --set-version). The hashes come from finalburnneo/FBNeo's tip, a
|
||||
# revision the libretro/FBNeo pin does not name: writing its version
|
||||
# left fbneo, fbneo_cps12 and fbneo_neogeo on a release their pin
|
||||
# never was.
|
||||
|
||||
files = profile.get("files", [])
|
||||
archive_files, non_archive = _split_files(files, lambda f: "archive" in f)
|
||||
@@ -1060,17 +1065,16 @@ def _backup_and_write_fbneo(path: str, data: dict, hashes: dict) -> None:
|
||||
"""Write merged FBNeo profile using text-based patching.
|
||||
|
||||
FBNeo profiles have individual ROM entries with archive: field.
|
||||
Patches core_version, the content values of existing entries that
|
||||
changed, and appends new ROM entries. Advancing core_version alone left
|
||||
a profile claiming a release whose crc32 it did not carry. Comments and
|
||||
curated source_refs stay as written.
|
||||
Patches the content values of existing entries that changed and
|
||||
appends new ROM entries. core_version is the pin's, never written here.
|
||||
Comments and curated source_refs stay as written.
|
||||
"""
|
||||
p = Path(path)
|
||||
backup = p.with_suffix(PROFILE_BACKUP_SUFFIX)
|
||||
shutil.copy2(p, backup)
|
||||
|
||||
original = p.read_text(encoding="utf-8")
|
||||
patched = _patch_core_version(original, data.get("core_version", ""))
|
||||
patched = original
|
||||
|
||||
# Identify new ROM entries by comparing parsed data keys, not text search
|
||||
existing_data = yaml_load(original) or {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -20,7 +21,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from common import github_token, source_yaml_paths, write_text_atomic, yaml_load
|
||||
from common import source_yaml_paths, write_text_atomic, yaml_load
|
||||
|
||||
from scripts.scraper._hash_merge import compute_diff, merge_fbneo_profile
|
||||
from scripts.scraper.fbneo_parser import parse_fbneo_source_tree
|
||||
@@ -51,7 +52,7 @@ def _is_cache_fresh() -> bool:
|
||||
|
||||
|
||||
def _sparse_clone() -> None:
|
||||
"""Sparse clone FBNeo repo, checking out only src/burn/drv."""
|
||||
"""Sparse clone FBNeo repo: src/burn/drv, and in cone mode src/burn/version.h."""
|
||||
if CLONE_DIR.exists():
|
||||
shutil.rmtree(CLONE_DIR)
|
||||
|
||||
@@ -74,7 +75,7 @@ def _sparse_clone() -> None:
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "sparse-checkout", "set", "src/burn/drv", "src/burner/resource.h"],
|
||||
["git", "sparse-checkout", "set", "src/burn/drv"],
|
||||
cwd=CLONE_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
@@ -82,31 +83,28 @@ def _sparse_clone() -> None:
|
||||
)
|
||||
|
||||
|
||||
# src/burn/version.h, as the libretro core prints it: "v%x.%x.%x.%02x" of
|
||||
# the BCD BURN_VERSION, so VER_ALPHA 3 reads 1.0.0.03. FBNeo bumps this file
|
||||
# without tagging: the last tag (v1.0.0.02) was 9 337 commits behind it.
|
||||
_VERSION_H = re.compile(
|
||||
r"#define\s+VER_MAJOR\s+(\d+)\s+#define\s+VER_MINOR\s+(\d+)\s+"
|
||||
r"#define\s+VER_BETA\s+(\d+)\s+#define\s+VER_ALPHA\s+(\d+)"
|
||||
)
|
||||
|
||||
|
||||
def version_from_header(text: str) -> str:
|
||||
"""The version src/burn/version.h states."""
|
||||
found = _VERSION_H.search(text)
|
||||
if not found:
|
||||
raise RuntimeError("src/burn/version.h states no VER_MAJOR..VER_ALPHA")
|
||||
major, minor, beta, alpha = (int(group) for group in found.groups())
|
||||
return f"v{major}.{minor}.{beta}.{alpha:02d}"
|
||||
|
||||
|
||||
def _extract_version() -> tuple[str, str]:
|
||||
"""Extract version tag and commit SHA from the cloned repo.
|
||||
|
||||
Returns (version, commit_sha). Falls back to resource.h if no tag.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
cwd=CLONE_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Prefer real version tags over pseudo-tags like "latest"
|
||||
version = "unknown"
|
||||
if result.returncode == 0:
|
||||
tag = result.stdout.strip()
|
||||
if tag and tag != "latest":
|
||||
version = tag
|
||||
# Fallback: resource.h
|
||||
if version == "unknown":
|
||||
version = _version_from_resource_h()
|
||||
# Last resort: use GitHub API for latest real release tag
|
||||
if version == "unknown":
|
||||
version = _version_from_api()
|
||||
|
||||
"""The version the cloned source states, and its commit."""
|
||||
header = CLONE_DIR / "src" / "burn" / "version.h"
|
||||
version = version_from_header(header.read_text(encoding="utf-8", errors="replace"))
|
||||
sha_result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=CLONE_DIR,
|
||||
@@ -114,49 +112,7 @@ def _extract_version() -> tuple[str, str]:
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
commit = sha_result.stdout.strip()
|
||||
|
||||
return version, commit
|
||||
|
||||
|
||||
def _version_from_api() -> str:
|
||||
"""The latest real release tag, or an error: "unknown" was written as
|
||||
core_version and cached for a day."""
|
||||
import json as json_mod
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
headers = {"User-Agent": "retrobios-scraper/1.0"}
|
||||
token = github_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/repos/finalburnneo/FBNeo/tags?per_page=10", headers=headers
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
tags = json_mod.loads(resp.read())
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
raise RuntimeError(f"cannot determine the FBNeo version: {exc}") from exc
|
||||
for tag in tags:
|
||||
if tag["name"] != "latest" and tag["name"].startswith("v"):
|
||||
return tag["name"]
|
||||
raise RuntimeError("cannot determine the FBNeo version: no tag, resource.h or release")
|
||||
|
||||
|
||||
def _version_from_resource_h() -> str:
|
||||
"""Fallback: parse VER_FULL_VERSION_STR from resource.h."""
|
||||
resource_h = CLONE_DIR / "src" / "burner" / "resource.h"
|
||||
if not resource_h.exists():
|
||||
return "unknown"
|
||||
|
||||
text = resource_h.read_text(encoding="utf-8", errors="replace")
|
||||
for line in text.splitlines():
|
||||
if "VER_FULL_VERSION_STR" in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return "unknown"
|
||||
return version, sha_result.stdout.strip()
|
||||
|
||||
|
||||
def _cleanup() -> None:
|
||||
|
||||
@@ -467,6 +467,13 @@ def release_profile(
|
||||
"source_commit": source_commit,
|
||||
"upstream_commit": upstream_commit,
|
||||
"core_version": version,
|
||||
# Where profile_sync reads it again: the makefile of the libretro/mame
|
||||
# tree the platform builds, at source_commit.
|
||||
"version_basis": {
|
||||
"kind": "file",
|
||||
"path": "makefile",
|
||||
"pattern": '#define BARE_BUILD_VERSION "([^"]+)"',
|
||||
},
|
||||
"display_name": f"Arcade (MAME {version})",
|
||||
"cores": cores,
|
||||
"built_by": built_by,
|
||||
|
||||
@@ -215,12 +215,12 @@ class Scraper(BaseScraper):
|
||||
if not entry["docs"]:
|
||||
del entry["docs"]
|
||||
|
||||
# The BiosDB has no release: the date it was built is the only
|
||||
# version the list carries, and the manifest needs one.
|
||||
timestamp = data.get("timestamp")
|
||||
released = (
|
||||
datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
|
||||
if isinstance(timestamp, (int, float))
|
||||
else ""
|
||||
)
|
||||
if not isinstance(timestamp, (int, float)):
|
||||
raise RuntimeError(f"{self.url}: the BiosDB records no timestamp")
|
||||
released = datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
return {
|
||||
"platform": "MiSTer FPGA",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Long lists as one filterable table, each row opening to its details.
|
||||
|
||||
MAME's card pages read 13,000 to 16,000 words, one card after another,
|
||||
before a reader found the archive they came for. Here each entry is one
|
||||
row: its name, the facts that tell rows apart, and the full card in a
|
||||
closed block under the name. The table is written as HTML so the block can
|
||||
hold the card's Markdown. It carries what rehype-stack-tables.mjs gives a
|
||||
Markdown table: `data-rb-stack` (one block per row on a phone) and the
|
||||
filter box above, which site.ts wires. Without JavaScript the rows show and
|
||||
every block opens. A closed block stays in the page, so the browser's find
|
||||
and the site search still reach every name it holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import sitemarkup as markup
|
||||
|
||||
# rehype-stack-tables.mjs RECORD_ROWS: from this many rows a table has a
|
||||
# filter box, and its stacked labels are written by site.ts, not the build.
|
||||
RECORD_ROWS = 20
|
||||
# filterPlaceholder in rehype-stack-tables.mjs: the column names offered.
|
||||
PLACEHOLDER_COLUMNS = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
"""One entry: ``title`` is the inline HTML of its name (a heading, so
|
||||
the site search lands on it), ``body`` the Markdown lines shown when the
|
||||
row opens, ``cells`` the HTML of the other columns."""
|
||||
|
||||
title: str
|
||||
body: list[str]
|
||||
cells: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def filter_placeholder(labels: list[str]) -> str:
|
||||
""""File, contents, status..." as rehype-stack-tables.mjs words it."""
|
||||
words = [label for label in labels if label][:PLACEHOLDER_COLUMNS]
|
||||
if not words:
|
||||
return "Type to filter..."
|
||||
lowered = [word if index == 0 or not (word[:1].isupper() and word[1:2].islower())
|
||||
else word[0].lower() + word[1:] for index, word in enumerate(words)]
|
||||
return f"{', '.join(lowered)}..."
|
||||
|
||||
|
||||
def filter_box(ident: str, heading: str, labels: list[str], rows: int, noun: str = "rows") -> str:
|
||||
"""The filter box rehype-stack-tables.mjs writes above a long table."""
|
||||
return (
|
||||
f'<div class="rb-table-filter"><label for="{ident}">Filter {html.escape(heading)}</label>'
|
||||
f'<input id="{ident}" type="search" autocomplete="off" '
|
||||
f'placeholder="{html.escape(filter_placeholder(labels))}">'
|
||||
f'<span class="rb-table-filter-count" aria-live="polite">{rows:,} {noun}</span></div>'
|
||||
)
|
||||
|
||||
|
||||
def card_title_and_body(card: list[str]) -> tuple[str, list[str]]:
|
||||
"""A card's heading line and the lines under it, its wrapping block
|
||||
(the first line and the last closing tag) taken off."""
|
||||
title_at = next(index for index, line in enumerate(card) if line.startswith("<h3"))
|
||||
closing = max(index for index, line in enumerate(card) if line.strip() == "</div>")
|
||||
body = card[title_at + 1:closing]
|
||||
while body and not body[0].strip():
|
||||
body.pop(0)
|
||||
while body and not body[-1].strip():
|
||||
body.pop()
|
||||
return card[title_at], body
|
||||
|
||||
|
||||
def entry_table(rows: list[Row], heads: list[str], ident: str, heading: str,
|
||||
stacked: list[str] | None = None,
|
||||
searched: frozenset[str] = frozenset()) -> list[str]:
|
||||
"""``rows`` under ``heads`` (the first names the column of titles).
|
||||
|
||||
``stacked`` gives a column's short label on a phone, as a header's
|
||||
`title` does in a Markdown table. A column every row leaves empty
|
||||
(``""``) is not written. The site search skips every column but the
|
||||
titles and those ``searched`` names: a count or a short hash is no word
|
||||
anyone types.
|
||||
"""
|
||||
keep = [0] + [column for column in range(1, len(heads))
|
||||
if any(row.cells[column - 1] for row in rows)]
|
||||
labels = [heads[c] for c in keep]
|
||||
short = [(stacked or heads)[c] for c in keep]
|
||||
skip = markup.search_exclude()
|
||||
# The block holds the box and the table: an open card spans its width.
|
||||
lines = ["", '<div class="rb-entries-wrap">']
|
||||
if len(rows) >= RECORD_ROWS:
|
||||
lines.append(filter_box(ident, heading, labels, len(rows)))
|
||||
lines.append('<table class="rb-entries" data-rb-stack="true">')
|
||||
head_cells = ""
|
||||
for label, name in zip(labels, short):
|
||||
title = f' title="{html.escape(name)}"' if name != label else ""
|
||||
head_cells += f'<th scope="col"{title}>{html.escape(label)}</th>'
|
||||
lines.extend(["<thead>", f"<tr>{head_cells}</tr>", "</thead>", "<tbody>"])
|
||||
for row in rows:
|
||||
lines.append("<tr>")
|
||||
if row.body:
|
||||
lines.extend(["<td>", '<details class="rb-entry">', f"<summary>{row.title}</summary>",
|
||||
"", *row.body, "", "</details>", "</td>"])
|
||||
else:
|
||||
lines.append(f"<td>{row.title}</td>")
|
||||
for column in keep[1:]:
|
||||
label = (f' data-label="{html.escape(short[keep.index(column)])}"'
|
||||
if len(rows) < RECORD_ROWS else "")
|
||||
ignored = "" if heads[column] in searched else f" {skip}"
|
||||
lines.append(f"<td{label}{ignored}>{row.cells[column - 1] or '-'}</td>")
|
||||
lines.append("</tr>")
|
||||
lines.extend(["</tbody>", "</table>", "</div>", ""])
|
||||
return lines
|
||||
@@ -156,13 +156,15 @@ def image(alt: str, src: str, **attributes: str) -> str:
|
||||
return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>'
|
||||
|
||||
|
||||
def hash_value(value: object) -> str:
|
||||
def hash_value(value: object, copy: bool = False) -> str:
|
||||
"""A hash printed whole, in the hash style. One element under Starlight:
|
||||
a span around a code span was 1,400 extra elements on a page of cards."""
|
||||
a span around a code span was 1,400 extra elements on a page of cards.
|
||||
*copy* marks it for the copy button the site script adds after it."""
|
||||
text = str(value).replace("`", "'")
|
||||
if _engine == "mkdocs":
|
||||
return f'<span class="rb-hash">`{text}`</span>'
|
||||
return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>'
|
||||
marker = ' data-copy=""' if copy else ""
|
||||
return f'<code class="rb-hash"{marker}>{html.escape(text, quote=False)}</code>'
|
||||
|
||||
|
||||
def block_open(tag: str, attributes: str = "") -> str:
|
||||
|
||||
+383
-41
@@ -18,6 +18,7 @@ its line at the last commit that touched measurements/.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -146,9 +147,21 @@ def index_item(entry: mr.Stored, runs: int, source: dict, profiles: dict,
|
||||
"runs": runs,
|
||||
"record_url": permalink(source["repo"], source["commit"], mr.records_path(entry.emulator), entry.line),
|
||||
"sqlite": f"downloads/measurements/{entry.emulator}.sqlite",
|
||||
"host_short": short_host(record["host"]),
|
||||
"setup": setup_of(record),
|
||||
"groups": variant_groups(record),
|
||||
"proves": proves(record["id"], profile),
|
||||
"timing": ({"ms_per_frame": record["timing"]["ms_per_frame"]["median"],
|
||||
"fps": record["timing"]["fps"]["median"],
|
||||
"stdev": record["timing"]["ms_per_frame"]["stdev"]}
|
||||
if record.get("timing") else None),
|
||||
}
|
||||
|
||||
|
||||
def with_title(item: dict) -> dict:
|
||||
return {**item, "title": short_title(item)}
|
||||
|
||||
|
||||
def build_index(stored: list[mr.Stored], source: dict | None, profiles: dict,
|
||||
system_names: dict[str, str] | None = None) -> dict:
|
||||
"""One item per (emulator, subject): its latest record not superseded."""
|
||||
@@ -161,7 +174,8 @@ def build_index(stored: list[mr.Stored], source: dict | None, profiles: dict,
|
||||
for key in sorted(by_subject):
|
||||
entries = by_subject[key]
|
||||
latest = max(entries, key=lambda s: (s.record["recorded_at"], s.line))
|
||||
items.append(index_item(latest, len(entries), source or {}, profiles, system_names or {}))
|
||||
items.append(with_title(index_item(latest, len(entries), source or {}, profiles,
|
||||
system_names or {})))
|
||||
items.sort(key=lambda item: (item["emulator"], item["date"], item["subject"]))
|
||||
return {
|
||||
"schema_version": INDEX_SCHEMA_VERSION,
|
||||
@@ -589,9 +603,14 @@ def build(public: Path, root: Path = ROOT, emulators_dir: Path = ROOT / "emulato
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def by_emulator(index: dict) -> dict[str, list[dict]]:
|
||||
"""Each emulator's items, with the count of its findings still open."""
|
||||
opened: dict[str, int] = {}
|
||||
for line in (index.get("campaign") or {}).get("open") or ():
|
||||
opened[line["emulator"]] = opened.get(line["emulator"], 0) + 1
|
||||
out: dict[str, list[dict]] = {}
|
||||
for item in index.get("items", []):
|
||||
out.setdefault(item["emulator"], []).append(item)
|
||||
out.setdefault(item["emulator"], []).append(
|
||||
{**item, "open_findings": opened.get(item["emulator"], 0)})
|
||||
return out
|
||||
|
||||
|
||||
@@ -607,52 +626,375 @@ def _short(commit: str | None) -> str:
|
||||
return f"`{commit[:12]}`" if commit else "unknown"
|
||||
|
||||
|
||||
def tested_item(item: dict) -> list[str]:
|
||||
"""One test: what ran, the result, the method and host beside it, the record."""
|
||||
title = item["claim"] or f"Benchmark of {item['content'] or 'the core alone'}"
|
||||
runs = "1 run" if item["runs"] == 1 else f"{item['runs']} runs, the latest shown"
|
||||
core = item["core"]
|
||||
build = f"{_text(core['library'])} at {_short(core['commit'])}" if core.get("commit") \
|
||||
else _text(core["library"])
|
||||
lines = [f"- **{_text(title)}**", ""]
|
||||
if len(item.get("verdicts") or ()) > 1:
|
||||
lines += [f" **Result:** `{item['status']}`.", ""]
|
||||
lines += [f" - {_text(v['variant'])}: `{v['verdict']}`. {_text(v['detail'])}."
|
||||
for v in item["verdicts"]]
|
||||
lines.append("")
|
||||
# A verdict or a comparison, as the block says it: words and a chip kind.
|
||||
RESULT_WORDS = {
|
||||
"boots": ("works", "success"),
|
||||
"bios_error": ("BIOS error", "danger"),
|
||||
"crash": ("crash", "danger"),
|
||||
"hang": ("hang", "warning"),
|
||||
"unknown": ("unknown", "muted"),
|
||||
"mixed": ("depends on the file", "info"),
|
||||
"same": ("same with every file", "muted"),
|
||||
"differs": ("depends on the file", "info"),
|
||||
"inconclusive": ("inconclusive", "warning"),
|
||||
"no verdict": ("no verdict", "muted"),
|
||||
"ran": ("ran", "muted"),
|
||||
}
|
||||
# Words a subject slug spells that read as one term.
|
||||
TERMS = {"bios": "BIOS", "vcd": "Video CD", "cd": "CD", "jp": "JP", "us": "US", "eu": "EU",
|
||||
"cr2": "CR2", "mpeg": "MPEG", "psx": "PSX", "hle": "HLE", "usa": "USA"}
|
||||
# The campaign's own check names, as the block says them.
|
||||
CHECK_NAMES = {"files": "full set", "none": "no BIOS", "negative": "wrong files",
|
||||
"references": "reference run", "variants": "copies", "alias": "other name",
|
||||
"path": "other path", "bench": "benchmark", "boot": "boot test", "alone": "file alone"}
|
||||
|
||||
|
||||
def _words(text: str) -> list[str]:
|
||||
return re.findall(r"[a-z0-9]+", str(text or "").lower())
|
||||
|
||||
|
||||
def short_content(label: str | None) -> str:
|
||||
"""A content label cut to the game's name: no source prefix, no
|
||||
edition, region or identity detail."""
|
||||
text = str(label or "").strip()
|
||||
if ":" in text.split("(")[0]:
|
||||
text = text.split(":", 1)[1].strip()
|
||||
for cut in (" - ", " (", ", "):
|
||||
text = text.split(cut, 1)[0]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def short_title(item: dict) -> str:
|
||||
"""A test's name in a few words: the file it varies, the game, and what
|
||||
the subject adds to them (a build, a region, a check of the campaign)."""
|
||||
thing = Path(str(item.get("variable") or "")).name
|
||||
game = short_content(item.get("content"))
|
||||
known = set(_words(thing)) | set(_words(item.get("content"))) | set(_words(game))
|
||||
known |= set(_words(Path(str((item.get("core") or {}).get("file") or "")).name.split("_libretro")[0]))
|
||||
axis = []
|
||||
for word in _words(item.get("subject")):
|
||||
if word in known or re.fullmatch(r"[0-9a-f]{8}", word):
|
||||
continue
|
||||
issue = re.fullmatch(r"issue(\d+)", word)
|
||||
word = f"issue {issue[1]}" if issue else CHECK_NAMES.get(word, TERMS.get(word, word))
|
||||
if word not in axis:
|
||||
axis.append(word)
|
||||
head = ", ".join(part for part in (f"`{thing}`" if thing else "", game) if part)
|
||||
title = f"{head} ({' '.join(axis)})" if axis and head else head or " ".join(axis)
|
||||
return title or str(item.get("subject") or "")
|
||||
|
||||
|
||||
OBSERVABLE_WORDS = {"video_sha1": "video", "frame_hashes": "screens", "audio_sha1": "audio",
|
||||
"state_sha1": "save state", "frame_sha1": "last frame"}
|
||||
|
||||
|
||||
def setup_of(record: dict) -> dict:
|
||||
"""What told two runs of one test apart, each as the words a title
|
||||
would use: the campaign check, the core build, the core options, the
|
||||
clock, the number of files tried and what was compared."""
|
||||
build = str(record["core"].get("build_command") or "")
|
||||
patched = re.search(r"\b(patch|edit(?:ed|ing)?)\b", build, re.IGNORECASE)
|
||||
named = re.search(r"\b([A-Z]+[0-9]+)\b", build) if patched else None
|
||||
check = record.get("check") or {}
|
||||
first = (_words(record["subject"]) or [""])[0]
|
||||
options = {}
|
||||
for key, value in sorted(record["inputs"].get("options", {}).items()):
|
||||
options[key] = f"{value} region" if key.endswith("_region") else f"{key.rsplit('_', 1)[-1]} {value}"
|
||||
observable = str(check.get("observable") or "")
|
||||
return {
|
||||
"check": CHECK_NAMES.get(first, ""),
|
||||
"build": (f"{named[1]} patch" if named else "patched build") if patched else "published build",
|
||||
"options": options,
|
||||
"clock": "fixed clock" if "FAKETIME" in record["inputs"].get("env", {}) else "running clock",
|
||||
"files": f"{len(record['variants'])} files" if record["kind"] == "check" else "",
|
||||
"observable": (observable.split(":", 1)[1] if observable.startswith("state:")
|
||||
else OBSERVABLE_WORDS.get(observable, observable)),
|
||||
}
|
||||
|
||||
|
||||
def _features(item: dict) -> dict[str, str]:
|
||||
"""A test's setup as named features, the options one by one."""
|
||||
setup = item.get("setup") or {}
|
||||
out = {name: str(setup.get(name) or "") for name in ("build", "clock", "files", "observable")}
|
||||
for key, value in (setup.get("options") or {}).items():
|
||||
out[f"option:{key}"] = value
|
||||
return out
|
||||
|
||||
|
||||
def distinct_titles(items: list[dict]) -> dict[str, str]:
|
||||
"""A title per item: its game and campaign check, then what sets it
|
||||
apart from the other tests of the same file, game and core.
|
||||
|
||||
A test whose setup is the common one of its group is named by its game.
|
||||
Another adds the features where it leaves the common setup (a patched
|
||||
build, a running clock, another region). Two titles still alike take
|
||||
every feature that varies in the group, then the subject's own words.
|
||||
Nothing is cut.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
peers: dict[tuple, list[dict]] = {}
|
||||
for item in items:
|
||||
game = short_content(item.get("content")) or short_title(item)
|
||||
peers.setdefault((item.get("variable") or "", game, item["core"].get("file")), []).append(item)
|
||||
for (_, game, _), group in peers.items():
|
||||
features = [_features(item) for item in group]
|
||||
names = sorted({name for f in features for name in f},
|
||||
key=lambda n: (n.startswith("option:"), ["build", "clock", "files", "observable"].index(n)
|
||||
if not n.startswith("option:") else 0, n))
|
||||
common = {}
|
||||
for name in names:
|
||||
values = [f.get(name, "") for f in features]
|
||||
ranked = sorted(set(values), key=lambda v: -values.count(v))
|
||||
# A tie has no common value: every test names its own.
|
||||
tied = len(ranked) > 1 and values.count(ranked[0]) == values.count(ranked[1])
|
||||
common[name] = None if tied else ranked[0]
|
||||
varying = [n for n in names if len({f.get(n, "") for f in features}) > 1]
|
||||
|
||||
def title(item: dict, own: dict[str, str], every: bool, varying: list[str] = varying,
|
||||
common: dict[str, str] = common, game: str = game) -> str:
|
||||
words = [(item.get("setup") or {}).get("check", "")]
|
||||
for name in varying:
|
||||
value = own.get(name, "")
|
||||
if every or value != common[name]:
|
||||
words.append(value or f"no {name.rsplit('_', 1)[-1]}")
|
||||
return ", ".join([game, *[w for w in words if w]])
|
||||
|
||||
for item, own in zip(group, features):
|
||||
out[item["id"]] = title(item, own, every=False)
|
||||
for every in (True, None):
|
||||
seen: dict[str, int] = {}
|
||||
for item in group:
|
||||
seen[out[item["id"]]] = seen.get(out[item["id"]], 0) + 1
|
||||
for item, own in zip(group, features):
|
||||
if seen[out[item["id"]]] > 1:
|
||||
if every:
|
||||
out[item["id"]] = title(item, own, every=True)
|
||||
else:
|
||||
axis = short_title({**item, "variable": None, "content": None})
|
||||
out[item["id"]] = f"{out[item['id']]}, {axis}"
|
||||
return out
|
||||
|
||||
|
||||
def short_host(host: dict) -> str:
|
||||
"""The computer as a reader names it: the CPU model and the OS."""
|
||||
cpu = str(host.get("cpu_model") or "unknown CPU")
|
||||
cpu = re.sub(r"\(R\)|\(TM\)|\bCPU\b|@.*$|^\d+(?:st|nd|rd|th) Gen ", "", cpu)
|
||||
cpu = " ".join(cpu.split())
|
||||
system = str(host.get("os") or "")
|
||||
name = "Linux" if "Linux" in system else system.split("-")[0] or "unknown OS"
|
||||
if "microsoft" in system.lower():
|
||||
name += " (WSL2)"
|
||||
return f"{cpu}, {name}"
|
||||
|
||||
|
||||
def variant_groups(record: dict) -> list[dict]:
|
||||
"""One entry per variant: the result group it falls in, and whether it
|
||||
staged no file. A run that carries verdicts is grouped by verdict, a
|
||||
comparison by the value its observable took."""
|
||||
check = record.get("check") or {}
|
||||
position: dict[str, int] = {}
|
||||
result: dict[str, str] = {}
|
||||
if all("verdict" in v for v in record["variants"]) and (
|
||||
record["kind"] == "run" or check.get("observable") == "frame_hashes"):
|
||||
for variant in record["variants"]:
|
||||
position.setdefault(variant["verdict"], len(position))
|
||||
result[variant["label"]] = RESULT_WORDS[variant["verdict"]][0]
|
||||
groups = {v["label"]: position[v["verdict"]] for v in record["variants"]}
|
||||
else:
|
||||
lines += [f" **Result:** `{item['status']}`. {_text(item['statement'])}.", ""]
|
||||
if item.get("method"):
|
||||
lines += [f" **Method:** {_text(item['method'])}", ""]
|
||||
lines += [
|
||||
f" **Ran on:** {_text(item['host'])}, {item['date'][:10]}. Core build: {build}. "
|
||||
f"{item['variants']} variant{'s' if item['variants'] != 1 else ''}, "
|
||||
f"{item['repetitions']} repetition{'s' if item['repetitions'] != 1 else ''} each"
|
||||
+ (f", {item['flags']} flag{'s' if item['flags'] != 1 else ''} raised" if item["flags"] else "")
|
||||
+ ".",
|
||||
"",
|
||||
f" [Full record]({item['record_url']}), {runs}.",
|
||||
"",
|
||||
groups = {}
|
||||
for number, group in enumerate((check.get("result") or {}).get("groups") or ()):
|
||||
for label in group["variants"]:
|
||||
groups[label] = number
|
||||
result[label] = f"result {chr(ord('A') + number % 26)}"
|
||||
varied = check.get("variable")
|
||||
return [{"variant": v["label"], "group": groups.get(v["label"], 0),
|
||||
"result": result.get(v["label"], ""),
|
||||
"absent": bool(varied) and varied in v["system_files"] and v["system_files"][varied] is None}
|
||||
for v in record["variants"]]
|
||||
|
||||
|
||||
def proves(record_id: str, profile: dict) -> list[str]:
|
||||
"""The profile fields this record is evidence for, as `field of file`."""
|
||||
out = []
|
||||
for entry in profile.get("files") or ():
|
||||
for name, ids in sorted((entry.get("evidence") or {}).items()):
|
||||
if record_id in ids:
|
||||
out.append(f"{name} of {entry.get('name')}")
|
||||
return out
|
||||
|
||||
|
||||
# An MDX page reads a brace as the start of an expression, in text and in
|
||||
# an attribute alike.
|
||||
_BRACES = str.maketrans({"{": "{", "}": "}"})
|
||||
|
||||
|
||||
def _attr(value: object) -> str:
|
||||
return html.escape(" ".join(str(value or "").split()), quote=True).translate(_BRACES)
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
"""Record text placed in raw HTML: escaped, code spans kept, a semicolon
|
||||
joining two clauses ending a sentence as on the rest of the site."""
|
||||
text = " ".join(str(value or "").split())
|
||||
text = re.sub(r";\s+(\w)", lambda m: ". " + m[1].upper(), text)
|
||||
return markup.inline_html(text).translate(_BRACES)
|
||||
|
||||
|
||||
def _dots(item: dict) -> str:
|
||||
groups = item.get("groups") or []
|
||||
names = "; ".join(f"{g['variant']}: {g['result']}" for g in groups)
|
||||
dots = "".join(
|
||||
f'<span class="rb-dot rb-dot-{g["group"] % 5}{" rb-dot-empty" if g["absent"] else ""}" '
|
||||
f'title="{_attr(g["variant"] + ": " + g["result"])}"></span>' for g in groups)
|
||||
return (f'<span class="rb-dots" role="img" aria-label="{_attr(f"{len(groups)} variants. {names}")}">'
|
||||
f"{dots}</span>")
|
||||
|
||||
|
||||
def _chip(item: dict) -> str:
|
||||
words, kind = RESULT_WORDS.get(item["status"], (item["status"], "muted"))
|
||||
return f'<span class="rb-badge rb-badge-{kind}">{html.escape(words)}</span>'
|
||||
|
||||
|
||||
def _sentence_html(label: str, value: object, markup_ready: bool = False) -> str:
|
||||
"""One line of the details, or nothing: an empty value is never printed."""
|
||||
text = (str(value or "") if markup_ready else _cell(value)).strip().rstrip(".")
|
||||
return f"<p><strong>{label}:</strong> {text}.</p>" if text else ""
|
||||
|
||||
|
||||
def _details(item: dict) -> list[str]:
|
||||
core = item["core"]
|
||||
build = _cell(core["library"]) + (f" at <code>{html.escape(core['commit'][:12])}</code>"
|
||||
if core.get("commit") else "")
|
||||
reps = f"{item['variants']} variant{'s' if item['variants'] != 1 else ''}, " \
|
||||
f"{item['repetitions']} repetition{'s' if item['repetitions'] != 1 else ''} each"
|
||||
if item["flags"]:
|
||||
reps += f", {item['flags']} flag{'s' if item['flags'] != 1 else ''} raised"
|
||||
runs = "" if item["runs"] == 1 else f", {item['runs']} runs, the latest shown"
|
||||
lines = [
|
||||
_sentence_html("Claim", item.get("claim")),
|
||||
_sentence_html("Result", item.get("statement")),
|
||||
_sentence_html("Method", item.get("method")),
|
||||
_sentence_html("Proves", ", ".join(item.get("proves") or [])),
|
||||
_sentence_html("Ran on", item.get("host_short") or item["host"]),
|
||||
_sentence_html("Core build", build and f"{build}. {reps}", markup_ready=True),
|
||||
f'<p><a href="{_attr(item["record_url"])}">Full record</a>{runs}.</p>',
|
||||
]
|
||||
return lines
|
||||
return [line for line in lines if line]
|
||||
|
||||
|
||||
def tested_counts(items: list[dict]) -> dict:
|
||||
tests = [i for i in items if i["kind"] != "bench"]
|
||||
return {"tests": len(items), "confirmed": sum(1 for i in items if i.get("proves")),
|
||||
"open": max((i.get("open_findings", 0) for i in items), default=0),
|
||||
"benchmarks": len(items) - len(tests),
|
||||
"last": max((i["date"][:10] for i in items), default="")}
|
||||
|
||||
|
||||
def core_label(item: dict) -> str:
|
||||
return Path(str(item["core"].get("file") or "")).name.split("_libretro")[0]
|
||||
|
||||
|
||||
def _row(item: dict, title: str, page_host: str, main_core: str) -> list[str]:
|
||||
elsewhere = (item.get("host_short") or item["host"]) != page_host
|
||||
title = _cell(title)
|
||||
if core_label(item) != main_core:
|
||||
title += f' <small>{html.escape(core_label(item))}</small>'
|
||||
return [
|
||||
'<details class="rb-tested-row">',
|
||||
("<summary>"
|
||||
f'<span class="rb-tested-test">{title}</span>'
|
||||
f'<span class="rb-tested-result">{_chip(item)}</span>'
|
||||
f'<span class="rb-tested-variants">{_dots(item)}</span>'
|
||||
f'<span class="rb-tested-date">{item["date"][:10]}'
|
||||
+ (f' <small>{_attr(item.get("host_short"))}</small>' if elsewhere else "")
|
||||
+ "</span></summary>"),
|
||||
'<div class="rb-tested-more">',
|
||||
*_details(item),
|
||||
"</div>",
|
||||
"</details>",
|
||||
]
|
||||
|
||||
|
||||
def by_file(items: list[dict]) -> list[tuple[str, list[dict]]]:
|
||||
"""The items under the file each varies, in order of first appearance;
|
||||
the runs that vary none last."""
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
groups.setdefault(Path(str(item.get("variable") or "")).name, []).append(item)
|
||||
return sorted(groups.items(), key=lambda kv: kv[0] == "")
|
||||
|
||||
|
||||
def summary_line(items: list[dict], page_host: str) -> str:
|
||||
"""N tests, the confirmed and open counts when there are any, the last
|
||||
run and its computer."""
|
||||
counts = tested_counts(items)
|
||||
parts = [f"{counts['tests']} test{'s' if counts['tests'] != 1 else ''}"]
|
||||
parts += [f"{counts[key]} {key}" for key in ("confirmed", "open") if counts[key]]
|
||||
parts.append(f"last run {counts['last']} on {page_host}")
|
||||
return " · ".join(parts) + "."
|
||||
|
||||
|
||||
def tested_section(name: str, items: list[dict]) -> list[str]:
|
||||
"""The Tested block of an emulator page, empty when nothing was recorded."""
|
||||
"""The Tested block of an emulator page, empty when nothing was recorded.
|
||||
|
||||
The answer first: one summary line, then the tests under the file each
|
||||
one varies, a row per test with its result chip, a dot per variant and
|
||||
its date. What proves it (method, claim, computer, core build, record)
|
||||
waits in each row's details. Tests recorded before the boot oracle fold
|
||||
under one line, and benchmarks get their own table at the end.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
lines = [
|
||||
"## Tested",
|
||||
"",
|
||||
f"This emulator was run in real conditions by the [core probe](../{METHOD_PAGE}). "
|
||||
f"Each test below is the latest of its kind. "
|
||||
f"[All measurements](../{EXPLORER_PAGE}), "
|
||||
f"[these records as SQLite](../downloads/measurements/{name}.sqlite).",
|
||||
"",
|
||||
]
|
||||
for item in sorted(items, key=lambda i: (i["date"], i["subject"]), reverse=True):
|
||||
lines += tested_item(item)
|
||||
return lines
|
||||
hosts = [i.get("host_short") or i["host"] for i in items]
|
||||
page_host = max(sorted(set(hosts)), key=hosts.count)
|
||||
cores = [core_label(i) for i in items]
|
||||
main_core = max(sorted(set(cores)), key=cores.count)
|
||||
titles = distinct_titles(items)
|
||||
links = (f'<a href="{markup.site_href(METHOD_PAGE.removesuffix(".md") + "/")}">How</a> · '
|
||||
f'<a href="{markup.site_href(EXPLORER_PAGE.removesuffix(".md") + "/")}">All tests</a> · '
|
||||
f'<a href="{markup.site_href(f"downloads/measurements/{name}.sqlite")}">SQLite</a>')
|
||||
ordered = sorted(items, key=lambda i: (i["date"], i["subject"], core_label(i)), reverse=True)
|
||||
benches = [i for i in ordered if i["kind"] == "bench"]
|
||||
earlier = [i for i in ordered if i["kind"] != "bench" and i["status"] == "no verdict"]
|
||||
current = [i for i in ordered if i["kind"] != "bench" and i["status"] != "no verdict"]
|
||||
lines = ["## Tested", "", '<div class="rb-tested">',
|
||||
f'<p class="rb-tested-summary">{html.escape(summary_line(items, page_host))} {links}</p>']
|
||||
if current or earlier:
|
||||
lines.append('<div class="rb-tested-head" aria-hidden="true"><span>Test</span>'
|
||||
"<span>Result</span><span>Variants</span><span>Date</span></div>")
|
||||
for file, group in by_file(current):
|
||||
label = f"<code>{html.escape(file)}</code>" if file else "No file varied"
|
||||
lines.append(f'<p class="rb-tested-file">{label}</p>')
|
||||
for item in group:
|
||||
lines += _row(item, titles[item["id"]], page_host, main_core)
|
||||
if current:
|
||||
lines.append('<p class="rb-tested-legend"><span class="rb-dot rb-dot-0"></span> A dot per file tried, '
|
||||
'the same colour for the same result. <span class="rb-dot rb-dot-0 rb-dot-empty">'
|
||||
"</span> No file staged.</p>")
|
||||
if earlier:
|
||||
lines += ['<details class="rb-tested-earlier">',
|
||||
f"<summary>{len(earlier)} earlier test{'s' if len(earlier) != 1 else ''}, "
|
||||
"before the boot oracle</summary>"]
|
||||
for item in earlier:
|
||||
file = Path(str(item.get("variable") or "")).name
|
||||
title = f"{file}, {titles[item['id']]}" if file else titles[item["id"]]
|
||||
lines += _row(item, title, page_host, main_core)
|
||||
lines.append("</details>")
|
||||
if benches:
|
||||
# One line: MDX reads a table split over lines as paragraphs.
|
||||
table = ['<table class="rb-tested-bench"><thead><tr><th>Benchmark</th><th>ms per frame</th>'
|
||||
"<th>fps</th><th>stdev</th><th>runs</th></tr></thead><tbody>"]
|
||||
for item in benches:
|
||||
timing = item.get("timing") or {}
|
||||
title = _cell(titles[item["id"]])
|
||||
if core_label(item) != main_core:
|
||||
title += f' <small>{html.escape(core_label(item))}</small>'
|
||||
table.append(
|
||||
f'<tr><td><a href="{_attr(item["record_url"])}">{title}</a></td>'
|
||||
f'<td>{timing.get("ms_per_frame", 0):.3f}</td><td>{timing.get("fps", 0):.1f}</td>'
|
||||
f'<td>{timing.get("stdev", 0):.3f}</td><td>{item["repetitions"]}</td></tr>')
|
||||
lines.append("".join([*table, "</tbody></table>"]))
|
||||
return [*lines, "</div>", ""]
|
||||
|
||||
|
||||
def hosts_table(index: dict) -> list[str]:
|
||||
|
||||
+27
-12
@@ -425,6 +425,24 @@ def _two_repositories(profile: dict) -> bool:
|
||||
def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
"""Render source_ref values as pinned links when their forge is known.
|
||||
|
||||
Each part is followed by whose code it cites, in words, when the profile
|
||||
reads two repositories. source_ref_items() gives the same parts with
|
||||
that role apart, for a page that names the roles once in a legend.
|
||||
"""
|
||||
rendered_groups = []
|
||||
for label, items in source_ref_items(profile, value):
|
||||
text = ", ".join(f"{part} ({role})" if role else part for part, role in items)
|
||||
if label:
|
||||
text = f"**{label}:** {text}"
|
||||
rendered_groups.append(text)
|
||||
return ", ".join(rendered_groups)
|
||||
|
||||
|
||||
def source_ref_items(profile: dict, value: str | dict | None) -> list[tuple[str, list[tuple[str, str]]]]:
|
||||
"""Each group of source_ref values: its label and its parts, each part
|
||||
as Markdown with the role_label() of the code it cites ("" when the
|
||||
profile reads one repository).
|
||||
|
||||
A profile can declare two repositories: the libretro port in ``source`` and
|
||||
the original emulator in ``upstream``. A path links into the one whose
|
||||
tree SOURCE_PATHS says holds it. Any other path is rendered as plain
|
||||
@@ -432,18 +450,18 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
link to the wrong repository is a false citation. An address inside a
|
||||
shipped binary reads as one (offset 0x8094-0x80ac in libnes.so).
|
||||
"""
|
||||
rendered_groups: list[str] = []
|
||||
groups: list[tuple[str, list[tuple[str, str]]]] = []
|
||||
path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$")
|
||||
for label, refs in source_ref_values(value):
|
||||
candidates = _forge_sources(profile, label)
|
||||
by_role = role_sources(profile, label)
|
||||
rendered: list[str] = []
|
||||
rendered: list[tuple[str, str]] = []
|
||||
current_role = ""
|
||||
binary = ""
|
||||
for part in split_source_ref(refs):
|
||||
if is_binary_citation(part.path):
|
||||
text, binary = _binary_citation(part.path, binary)
|
||||
rendered.append(text)
|
||||
rendered.append((text, ""))
|
||||
continue
|
||||
binary = ""
|
||||
role, bare = ref_role(part.path)
|
||||
@@ -473,22 +491,19 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
else:
|
||||
selected = _holder(candidates, part.path)
|
||||
|
||||
tag = (f" ({role_label(profile, current_role, part.path)})"
|
||||
if current_role and _two_repositories(profile) else "")
|
||||
role = (role_label(profile, current_role, part.path)
|
||||
if current_role and _two_repositories(profile) else "")
|
||||
if selected is None:
|
||||
rendered.append(f"`{display}`{tag}")
|
||||
rendered.append((f"`{display}`", role))
|
||||
else:
|
||||
repo, pin = selected
|
||||
url = _source_permalink(
|
||||
repo, pin, real_path, part.start, part.end
|
||||
)
|
||||
rendered.append(f"[`{display}`]({url}){tag}")
|
||||
rendered.append((f"[`{display}`]({url})", role))
|
||||
|
||||
text = ", ".join(rendered) if rendered else f"`{refs}`"
|
||||
if label:
|
||||
text = f"**{label}:** {text}"
|
||||
rendered_groups.append(text)
|
||||
return ", ".join(rendered_groups)
|
||||
groups.append((label, rendered or [(f"`{refs}`", "")]))
|
||||
return groups
|
||||
|
||||
|
||||
def _binary_citation(path: str, previous: str) -> tuple[str, str]:
|
||||
|
||||
@@ -809,19 +809,23 @@ MOVED_PAGES = {"wiki/getting-started.md": "which-pack.md"}
|
||||
_CORE_NAME = re.compile(r"[a-z0-9_-]+")
|
||||
|
||||
|
||||
def redirects(profiles: dict, present: set[str]) -> dict[str, str]:
|
||||
def redirects(profiles: dict, present: set[str],
|
||||
moved: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Old and alternative addresses, for src/generated/redirects.json.
|
||||
|
||||
A page that moved, once its source is gone, and each name a profile's
|
||||
`cores:` gives the emulator (the buildbot's `mednafen_psx` for
|
||||
beetle_psx), to the profile's page. A name two profiles claim, or one
|
||||
that is already a page's address, is left out. Addresses are written
|
||||
under the site root, without its base."""
|
||||
under the site root, without its base. ``moved`` adds pages that moved
|
||||
by generation, old page to new page, the new one with an optional
|
||||
#fragment."""
|
||||
def address(rel: str) -> str:
|
||||
return "/" + page_url(rel)[len(BASE):]
|
||||
page, _, fragment = rel.partition("#")
|
||||
return "/" + page_url(page)[len(BASE):] + (f"#{fragment}" if fragment else "")
|
||||
|
||||
out = {address(old): address(new) for old, new in MOVED_PAGES.items()
|
||||
if old not in present and new in present}
|
||||
out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
|
||||
if old not in present and new.partition("#")[0] in present}
|
||||
pages = {rel.casefold() for rel in present}
|
||||
claims: dict[str, set[str]] = {}
|
||||
for key, profile in profiles.items():
|
||||
|
||||
@@ -12,6 +12,7 @@ import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -1086,3 +1087,103 @@ def commits_touching(
|
||||
1 for entry in payload
|
||||
if isinstance(entry, dict) and (entry.get("sha") or entry.get("id")) != base
|
||||
)
|
||||
|
||||
|
||||
# Commit graphs kept to count the commits between a tag and a pin. A
|
||||
# treeless bare clone holds commits and tags only: the files a profile reads
|
||||
# come from fetch_file, by sha.
|
||||
GIT_DIR = "_git"
|
||||
|
||||
|
||||
def clone_dir(cache_dir: str, repo: Repo) -> Path:
|
||||
"""Where the commit graph of a repository is kept."""
|
||||
return Path(cache_dir) / GIT_DIR / repo.host / repo.owner / f"{repo.name}.git"
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, env=env, check=False
|
||||
)
|
||||
|
||||
|
||||
def _has_commit(clone: Path, sha: str) -> bool:
|
||||
return _git(["cat-file", "-e", f"{sha}^{{commit}}"], clone).returncode == 0
|
||||
|
||||
|
||||
def _graph(repo: Repo, sha: str, cache_dir: str, offline: bool) -> Path:
|
||||
"""A clone holding sha and every tag, fetched again once tags may have moved."""
|
||||
clone = clone_dir(cache_dir, repo)
|
||||
url = f"https://{repo.host}/{repo.slug}.git"
|
||||
if not (clone / "HEAD").is_file():
|
||||
if offline:
|
||||
raise NotCachedError(f"{repo.slug}: no commit graph in the offline cache")
|
||||
clone.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = _git(["clone", "--quiet", "--bare", "--filter=tree:0", url, str(clone)])
|
||||
if result.returncode:
|
||||
raise UpstreamError(f"{url}: clone failed: {result.stderr.strip()}")
|
||||
_git(["fetch", "--quiet", "--filter=tree:0", "--tags", "origin"], clone)
|
||||
stamp = clone / "FETCH_HEAD"
|
||||
fresh = stamp.is_file() and time.time() - stamp.stat().st_mtime <= MOVING_TTL
|
||||
if offline or (fresh and _has_commit(clone, sha)):
|
||||
if not _has_commit(clone, sha):
|
||||
raise NotCachedError(f"{repo.slug}@{sha[:8]}: not in the offline commit graph")
|
||||
return clone
|
||||
# Tags are fetched again: a tag pushed since the clone can be nearer.
|
||||
result = _git(
|
||||
["fetch", "--quiet", "--filter=tree:0", "--force", "--tags", "origin",
|
||||
"+refs/heads/*:refs/heads/*"], clone,
|
||||
)
|
||||
if result.returncode:
|
||||
raise UpstreamError(f"{url}: fetch failed: {result.stderr.strip()}")
|
||||
if not _has_commit(clone, sha):
|
||||
# A commit no branch reaches any more: a rewritten fork, a PR head.
|
||||
_git(["fetch", "--quiet", "--filter=tree:0", "origin", sha], clone)
|
||||
if not _has_commit(clone, sha):
|
||||
raise UpstreamError(f"{url}: {sha[:8]} is on no branch the forge serves")
|
||||
return clone
|
||||
|
||||
|
||||
def describe(
|
||||
repo: Repo, sha: str, cache_dir: str, offline: bool = False,
|
||||
match: str | None = None,
|
||||
) -> tuple[str, int] | None:
|
||||
"""The tag nearest to sha in its history, and the commits after it.
|
||||
|
||||
git describe counts the commits sha has that the tag lacks, the count a
|
||||
forge compare reports as ahead. ``match`` is a glob the tag must fit.
|
||||
None when no tag is reachable from sha.
|
||||
"""
|
||||
clone = _graph(repo, sha, cache_dir, offline)
|
||||
argv = ["describe", "--tags", "--long", "--abbrev=40"]
|
||||
if match:
|
||||
argv += ["--match", match]
|
||||
result = _git([*argv, sha], clone)
|
||||
if result.returncode:
|
||||
if any(text in result.stderr for text in (
|
||||
"No names found", "No tags can describe", "cannot describe",
|
||||
)):
|
||||
return None
|
||||
raise UpstreamError(f"{repo.slug}: describe {sha[:8]}: {result.stderr.strip()}")
|
||||
tag, _distance, _commit = result.stdout.strip().rsplit("-", 2)
|
||||
# describe's own count is an estimate across merges (ares: 149 where the
|
||||
# pin holds 143 commits the tag lacks): the exact count is rev-list's.
|
||||
counted = _git(["rev-list", "--count", f"refs/tags/{tag}..{sha}"], clone)
|
||||
if counted.returncode:
|
||||
raise UpstreamError(f"{repo.slug}: {tag}..{sha[:8]}: {counted.stderr.strip()}")
|
||||
return tag, int(counted.stdout.strip())
|
||||
|
||||
|
||||
def graph_ancestry(
|
||||
repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False
|
||||
) -> tuple[int, int]:
|
||||
"""(ahead, behind) counted in the commit graph, for a pair a forge
|
||||
refuses to compare (GitHub answers 422 past a diff size)."""
|
||||
clone = _graph(repo, head, cache_dir, offline)
|
||||
counts = []
|
||||
for spec in (f"{base}..{head}", f"{head}..{base}"):
|
||||
result = _git(["rev-list", "--count", spec], clone)
|
||||
if result.returncode:
|
||||
raise UpstreamError(f"{repo.slug}: {spec}: {result.stderr.strip()}")
|
||||
counts.append(int(result.stdout.strip()))
|
||||
return counts[0], counts[1]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Read a profile's core_version from the source its version_basis records.
|
||||
|
||||
A profile states the version of the code it was read at. Copied from a
|
||||
libretro .info or left behind by a pin advance, it named another release:
|
||||
snes9x said 1.61 at a pin whose snes9x.h states 1.63, and four bulk advances
|
||||
moved 295 pins without touching one version. version_basis records where the
|
||||
version is read, so a tool can read it again at any pin:
|
||||
|
||||
- ``file``: a pattern in one file of a declared repository at its pin, the
|
||||
version the program states (a macro, a build file, a package file).
|
||||
- ``tag``: the nearest tag in the pin's history and the commits after it,
|
||||
for code that takes its version from git at build time. core_version is
|
||||
the tag at distance 0, else ``<tag>-<distance>-g<pin7>``, git describe's
|
||||
form.
|
||||
- ``alias``: the same binary as another profile, whose version it carries.
|
||||
- ``package``: an artefact read outside any source tree (an Android package,
|
||||
a closed binary, a release archive): a version no pin can be asked for.
|
||||
A pattern reads it from the artefact's name when the name states it.
|
||||
- ``none``: the code at the pin states no version and no tag reaches it.
|
||||
core_version is then null.
|
||||
- ``unverified``: no reading could be made (``why``: no pin, a host the
|
||||
tools cannot read, a forge gone, no build named as read, no source).
|
||||
core_version stays as written and is reported unconfirmed.
|
||||
|
||||
A profile whose builds live in different repositories keys version_basis by
|
||||
build mode, the way it keys core_version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import upstream
|
||||
|
||||
KINDS = ("file", "tag", "alias", "package", "none", "unverified")
|
||||
# Values copied from a .info or a branch name, which name no version.
|
||||
PLACEHOLDERS = frozenset({"git", "svn", "unknown", "master", "main", "latest", "dev", "head"})
|
||||
# Pin field of each repository field a basis may name. A mirror carries the
|
||||
# source's history, so source_commit pins it too.
|
||||
PIN_FIELDS = {"source": "source_commit", "source_mirror": "source_commit",
|
||||
"upstream": "upstream_commit"}
|
||||
|
||||
MATCH = "MATCH"
|
||||
DRIFT = "DRIFT"
|
||||
UNREADABLE = "UNREADABLE"
|
||||
NO_BASIS = "NO_BASIS"
|
||||
UNCHECKED = "UNCHECKED"
|
||||
FAILING = (DRIFT, UNREADABLE, NO_BASIS)
|
||||
|
||||
_FIELD = re.compile(r"\{(\d+)(?::([^}]*))?\}")
|
||||
|
||||
|
||||
class VersionUnreadable(Exception):
|
||||
"""The basis yields no version at the pin it is asked about."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reading:
|
||||
mode: str
|
||||
kind: str
|
||||
declared: str | None
|
||||
read: str | None
|
||||
where: str
|
||||
status: str
|
||||
# What a write sets beside core_version: the tag and distance of a tag basis.
|
||||
updates: tuple[tuple[str, object], ...] = ()
|
||||
|
||||
|
||||
def bases(profile: dict) -> dict[str, dict]:
|
||||
"""The basis of each build mode, "" when one covers every build."""
|
||||
basis = profile.get("version_basis")
|
||||
if not isinstance(basis, dict):
|
||||
return {}
|
||||
if "kind" in basis:
|
||||
return {"": basis}
|
||||
return {str(mode): value for mode, value in basis.items() if isinstance(value, dict)}
|
||||
|
||||
|
||||
def declared(profile: dict, mode: str) -> str | None:
|
||||
"""core_version for a build mode, as written."""
|
||||
value = profile.get("core_version")
|
||||
if isinstance(value, dict):
|
||||
value = value.get(mode)
|
||||
return None if value is None else str(value)
|
||||
|
||||
|
||||
def _by_mode(profile: dict, field: str, mode: str) -> str | None:
|
||||
value = profile.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get(mode)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def is_placeholder(value: object) -> bool:
|
||||
"""A VCS or branch word standing for a version, alone or annotated
|
||||
("SVN (2015 snapshot)"). The schema refuses the same strings."""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
return re.split(r"[\s(]", value.strip(), maxsplit=1)[0].casefold() in PLACEHOLDERS
|
||||
|
||||
|
||||
def format_version(match: re.Match, template: str | None) -> str:
|
||||
"""The groups of a match laid out by template, `{1}.{2}` or `{4:02d}`.
|
||||
|
||||
A format spec applies to the group read as an integer: FBNeo writes its
|
||||
alpha number on two digits (VER_ALPHA 3 is 1.0.0.03).
|
||||
"""
|
||||
if not template:
|
||||
return match.group(1).strip()
|
||||
|
||||
def field(found: re.Match) -> str:
|
||||
text = match.group(int(found.group(1))) or ""
|
||||
spec = found.group(2)
|
||||
return format(int(text, 0), spec) if spec else text.strip()
|
||||
|
||||
return _FIELD.sub(field, template)
|
||||
|
||||
|
||||
def read_text_version(text: str, pattern: str, template: str | None) -> tuple[str, int] | None:
|
||||
"""The version a pattern reads in a file's text, and its line number."""
|
||||
found = re.search(pattern, text, re.MULTILINE)
|
||||
if not found:
|
||||
return None
|
||||
return format_version(found, template), text.count("\n", 0, found.start()) + 1
|
||||
|
||||
|
||||
def describe_form(tag: str, distance: int, pin: str) -> str:
|
||||
"""core_version of a tag basis: the tag, or git describe's long form."""
|
||||
return tag if distance == 0 else f"{tag}-{distance}-g{pin[:7]}"
|
||||
|
||||
|
||||
def _repo_and_pin(
|
||||
profile: dict, basis: dict, mode: str, cache_dir: str, offline: bool,
|
||||
) -> tuple[upstream.Repo, str]:
|
||||
"""The repository a basis reads and the commit it is read at."""
|
||||
if basis.get("url"):
|
||||
repo = upstream.parse_repo(str(basis["url"]))
|
||||
pin = str(basis.get("commit") or "")
|
||||
where = str(basis["url"])
|
||||
else:
|
||||
field = str(basis.get("repo") or "source")
|
||||
url = _by_mode(profile, field, mode) or ""
|
||||
repo = upstream.parse_repo(url)
|
||||
pin = _by_mode(profile, PIN_FIELDS.get(field, ""), mode) or ""
|
||||
where = url or field
|
||||
if repo is not None and not pin and field == "upstream" and profile.get("profiled_date"):
|
||||
# The pin of a second repository falls back on profiled_date,
|
||||
# as profile_sync resolves it.
|
||||
pin = upstream.resolve_commit_at(
|
||||
repo, str(profile["profiled_date"]), cache_dir, offline) or ""
|
||||
if repo is None:
|
||||
raise VersionUnreadable(f"{where}: not a forge repository")
|
||||
if not pin:
|
||||
raise VersionUnreadable(f"{repo.slug}: no pinned commit")
|
||||
return repo, pin
|
||||
|
||||
|
||||
def _ancestry(
|
||||
repo: upstream.Repo, base: str, head: str, cache_dir: str, offline: bool,
|
||||
) -> tuple[int, int] | None:
|
||||
"""The forge's compare, else the commit graph: GitHub refuses with 422
|
||||
a compare whose diff is too large (aps3e's tag and pin)."""
|
||||
try:
|
||||
relation = upstream.ancestry(repo, base, head, cache_dir, offline)
|
||||
except upstream.RateLimitError:
|
||||
raise
|
||||
except upstream.UpstreamError:
|
||||
relation = None
|
||||
if relation is None:
|
||||
try:
|
||||
relation = upstream.graph_ancestry(repo, base, head, cache_dir, offline)
|
||||
except upstream.NotCachedError:
|
||||
return None
|
||||
return relation
|
||||
|
||||
|
||||
def read(
|
||||
profile: dict, mode: str, basis: dict, cache_dir: str, offline: bool,
|
||||
profiles: dict | None = None, recompute: bool = False,
|
||||
) -> Reading:
|
||||
"""The version the basis yields at the profile's pin, against core_version.
|
||||
|
||||
``recompute`` asks git for the nearest tag again, the way a pin advance
|
||||
has to: a recorded tag only proves its own distance, and a tag released
|
||||
between the two pins would go unseen.
|
||||
"""
|
||||
stated = declared(profile, mode)
|
||||
kind = str(basis.get("kind") or "")
|
||||
|
||||
def reading(value: str | None, where: str, updates: tuple = ()) -> Reading:
|
||||
status = MATCH if value == stated else DRIFT
|
||||
return Reading(mode, kind, stated, value, where, status, updates)
|
||||
|
||||
def unreadable(reason: str) -> Reading:
|
||||
return Reading(mode, kind, stated, None, reason, UNREADABLE)
|
||||
|
||||
try:
|
||||
if kind == "none":
|
||||
if recompute:
|
||||
# A release tagged since the basis was written gives the
|
||||
# code a version: the advance has to name it.
|
||||
repo, pin = _repo_and_pin(profile, basis, mode, cache_dir, offline)
|
||||
described = upstream.describe(repo, pin, cache_dir, offline)
|
||||
if described is not None:
|
||||
return Reading(mode, kind, stated, describe_form(*described, pin),
|
||||
f"{repo.slug}: tag {described[0]} is now in "
|
||||
f"{pin[:7]}'s history", DRIFT)
|
||||
return reading(None, "the code states no version")
|
||||
if kind == "unverified":
|
||||
return Reading(mode, kind, stated, None, f"not verified: {basis.get('why')}",
|
||||
UNCHECKED)
|
||||
if kind == "package":
|
||||
artifact = str(basis.get("artifact") or "")
|
||||
if basis.get("pattern"):
|
||||
# An artefact named after its version says it in its name:
|
||||
# advancemame-0.94.0.tar.gz.
|
||||
found = read_text_version(artifact, str(basis["pattern"]), basis.get("format"))
|
||||
if found is None:
|
||||
return unreadable(f"{artifact}: pattern not found")
|
||||
return reading(found[0], f"named by {artifact}")
|
||||
return Reading(mode, kind, stated, None, f"read from {artifact}", UNCHECKED)
|
||||
if kind == "alias":
|
||||
other = (profiles or {}).get(str(basis.get("of") or ""))
|
||||
if other is None:
|
||||
return unreadable(f"alias of {basis.get('of')}: no such profile")
|
||||
return reading(declared(other, mode) if isinstance(other.get("core_version"), dict)
|
||||
else declared(other, ""), f"alias of {basis['of']}")
|
||||
repo, pin = _repo_and_pin(profile, basis, mode, cache_dir, offline)
|
||||
if kind == "file":
|
||||
path = str(basis.get("path") or "")
|
||||
lines = upstream.fetch_file(repo, pin, path, cache_dir, offline)
|
||||
if lines is None:
|
||||
return unreadable(f"{repo.slug}@{pin[:7]}:{path}: absent")
|
||||
found = read_text_version("\n".join(lines), str(basis.get("pattern") or ""),
|
||||
basis.get("format"))
|
||||
if found is None:
|
||||
return unreadable(f"{repo.slug}@{pin[:7]}:{path}: pattern not found")
|
||||
value, line = found
|
||||
return reading(value, f"{repo.slug}@{pin[:7]}:{path}:{line}")
|
||||
if kind == "tag":
|
||||
if recompute:
|
||||
described = upstream.describe(repo, pin, cache_dir, offline, basis.get("match"))
|
||||
if described is None:
|
||||
return unreadable(f"{repo.slug}@{pin[:7]}: no tag in its history")
|
||||
tag, distance = described
|
||||
else:
|
||||
tag, distance = str(basis.get("tag") or ""), basis.get("distance")
|
||||
commit = upstream.tag_commit(repo, tag, cache_dir, offline)
|
||||
if commit is None:
|
||||
return unreadable(f"{repo.slug}: no tag {tag}")
|
||||
relation = (0, 0) if commit == pin else _ancestry(
|
||||
repo, commit, pin, cache_dir, offline)
|
||||
if relation is None:
|
||||
return unreadable(f"{repo.slug}: {tag}...{pin[:7]} not comparable")
|
||||
if relation[1]:
|
||||
return unreadable(f"{repo.slug}: tag {tag} is not in {pin[:7]}'s history")
|
||||
if relation[0] != distance:
|
||||
return Reading(mode, kind, stated, describe_form(tag, relation[0], pin),
|
||||
f"{repo.slug}: {pin[:7]} is {relation[0]} commits after "
|
||||
f"{tag}, the basis says {distance}", DRIFT,
|
||||
(("distance", relation[0]),))
|
||||
return reading(describe_form(tag, distance, pin),
|
||||
f"{repo.slug}: tag {tag} + {distance}",
|
||||
(("tag", tag), ("distance", distance)))
|
||||
except (upstream.UpstreamError, VersionUnreadable) as exc:
|
||||
return unreadable(str(exc))
|
||||
return unreadable(f"unknown kind {kind!r}")
|
||||
|
||||
|
||||
def check(
|
||||
profile: dict, cache_dir: str, offline: bool, profiles: dict | None = None,
|
||||
recompute: bool = False,
|
||||
) -> list[Reading]:
|
||||
"""One reading per build mode, or a NO_BASIS reading."""
|
||||
found = bases(profile)
|
||||
if not found:
|
||||
return [Reading("", "", declared(profile, ""), None, "no version_basis", NO_BASIS)]
|
||||
return [read(profile, mode, basis, cache_dir, offline, profiles, recompute)
|
||||
for mode, basis in found.items()]
|
||||
Reference in new issue
Block a user