mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
chore: merge main into the launchers
This commit is contained in:
commit
0369c75dfe
317 files changed
+22514
-2652
No files matched your search
@@ -12,7 +12,7 @@ and 3-tier storage (embedded/external/user_provided).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
|
||||
from artifacts import _build_timestamp, set_plain_mode
|
||||
import argparse
|
||||
@@ -4095,6 +4095,32 @@ def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[...,
|
||||
CASE_FOLDING_OS = frozenset({"windows", "darwin"})
|
||||
|
||||
|
||||
def system_titles(*entry_lists: Iterable[dict]) -> dict[str, str]:
|
||||
"""The name a person reads for each system the entries carry.
|
||||
|
||||
Batocera names PlayStation `psx` and the installer showed
|
||||
`sony-playstation (psx)`: neither is what a beginner looks for. The
|
||||
title is the one the site prints, so a system reads the same on its
|
||||
page and in the installer. A system the site cannot name is left out,
|
||||
and the installer shows its id: the site build refuses such a system,
|
||||
so only a test fixture reaches here with one.
|
||||
"""
|
||||
from siterender import system_name
|
||||
|
||||
ids = {
|
||||
sys_id
|
||||
for entries in entry_lists for entry in entries
|
||||
for sys_id in entry.get("systems") or []
|
||||
}
|
||||
titles: dict[str, str] = {}
|
||||
for sys_id in sorted(ids):
|
||||
try:
|
||||
titles[sys_id] = system_name(sys_id)
|
||||
except KeyError:
|
||||
continue
|
||||
return titles
|
||||
|
||||
|
||||
def generate_manifest(
|
||||
platform_name: str,
|
||||
platforms_dir: str,
|
||||
@@ -4562,6 +4588,7 @@ def generate_manifest(
|
||||
# The names the platform itself gives its systems (Batocera's psx),
|
||||
# which --system accepts beside the ids.
|
||||
"system_names": platform_system_names(config, list(pack_systems)),
|
||||
"system_titles": system_titles(manifest_files, omitted_by_destination.values()),
|
||||
"files": manifest_files,
|
||||
}
|
||||
if data_directories:
|
||||
|
||||
@@ -8532,6 +8532,18 @@ def generate_which_pack() -> str:
|
||||
*fence("powershell", install_command(
|
||||
"powershell", "--platform retroarch --dest E:\\retroarch\\cores\\system")),
|
||||
]),
|
||||
*markup.details("note", "Copy the BIOS files from your old card", [
|
||||
"Plug the old card in and add `--from` with its folder. The BIOS files "
|
||||
"found there are copied, and the rest downloaded. The old card is only read.",
|
||||
"",
|
||||
"Linux, macOS:",
|
||||
"",
|
||||
*fence("sh", install_command("sh", "--from /media/me/OLDCARD")),
|
||||
"",
|
||||
"Windows (PowerShell, E: being the old card):",
|
||||
"",
|
||||
*fence("powershell", install_command("powershell", "--from E:\\")),
|
||||
]),
|
||||
"Details: [All installer options](wiki/installer.md).",
|
||||
"",
|
||||
*_next_block([
|
||||
|
||||
+427
-108
@@ -306,11 +306,13 @@ class Citation:
|
||||
path_span: tuple[int, int] = (0, 0)
|
||||
spans: tuple = ()
|
||||
parts: tuple = ()
|
||||
# Broken across two lines of a literal block: compared like any run,
|
||||
# never rewritten, since no single line carries it.
|
||||
# Broken across two lines of a literal block. joined_at is where the
|
||||
# second line starts in the joined run line, so each span goes back to
|
||||
# the line that holds it.
|
||||
wrapped: bool = False
|
||||
# Written in a YAML comment, which the loaded document drops: compared,
|
||||
# never rewritten, since no scalar holds it.
|
||||
joined_at: int = 0
|
||||
# Written in a YAML comment, which the loaded document drops. line is
|
||||
# then the physical line of the file and the spans index it.
|
||||
in_comment: bool = False
|
||||
|
||||
|
||||
@@ -388,11 +390,14 @@ def comment_citations(
|
||||
if inside_scalar(offset + column):
|
||||
continue
|
||||
comment = line[column + 1:].rstrip("\r\n")
|
||||
shift = column + 1
|
||||
for _, path_span, run_spans, parts, run, _ in _prose_runs(comment, known):
|
||||
citations.append(Citation(
|
||||
field=f"comment line {number + 1}", kind="prose", ref=run,
|
||||
path_span=path_span, spans=run_spans, parts=parts,
|
||||
in_comment=True,
|
||||
line=number,
|
||||
path_span=(path_span[0] + shift, path_span[1] + shift),
|
||||
spans=tuple((lo + shift, hi + shift) for lo, hi in run_spans),
|
||||
parts=parts, in_comment=True,
|
||||
))
|
||||
break
|
||||
offset += len(line)
|
||||
@@ -422,22 +427,22 @@ def collect_citations(document: dict, text: str | None = None) -> list[Citation]
|
||||
holder=holder, key=key, label=label,
|
||||
))
|
||||
if isinstance(value, str):
|
||||
for line, path_span, spans, parts, run, wrapped in _prose_runs(
|
||||
for line, path_span, spans, parts, run, joined_at in _prose_runs(
|
||||
ref_prose_text(value), known
|
||||
):
|
||||
citations.append(Citation(
|
||||
field=field, kind="prose", ref=run, holder=holder,
|
||||
key=key, line=line, path_span=path_span, spans=spans,
|
||||
parts=parts, wrapped=wrapped,
|
||||
parts=parts, wrapped=bool(joined_at), joined_at=joined_at,
|
||||
))
|
||||
continue
|
||||
for line, path_span, spans, parts, run, wrapped in _prose_runs(
|
||||
for line, path_span, spans, parts, run, joined_at in _prose_runs(
|
||||
value, known
|
||||
):
|
||||
citations.append(Citation(
|
||||
field=field, kind="prose", ref=run, holder=holder, key=key,
|
||||
line=line, path_span=path_span, spans=spans, parts=parts,
|
||||
wrapped=wrapped,
|
||||
wrapped=bool(joined_at), joined_at=joined_at,
|
||||
))
|
||||
if text is not None:
|
||||
citations.extend(comment_citations(text, known))
|
||||
@@ -519,7 +524,14 @@ def _map_changed(pin: list[str], head: list[str], lo: int, hi: int) -> AnchorRes
|
||||
"CHANGED", None, None, [], f"file over {MAX_MATCH_LINES} lines"
|
||||
)
|
||||
opcodes = _opcodes(pin, head)
|
||||
if not any(tag == "equal" for tag, *_ in opcodes):
|
||||
# A file whose every line was edited in place keeps no equal block, yet
|
||||
# its lines still map one to one: fheroes2 version.txt holds one line,
|
||||
# 1.0.11 at the pin and 1.1.17 at HEAD, and read GONE.
|
||||
rewritten_in_place = len(opcodes) == 1 and (
|
||||
opcodes[0][0] == "replace"
|
||||
and opcodes[0][2] - opcodes[0][1] == opcodes[0][4] - opcodes[0][3]
|
||||
)
|
||||
if not rewritten_in_place and not any(tag == "equal" for tag, *_ in opcodes):
|
||||
return AnchorResult("GONE", None, None, [])
|
||||
new_lo = _map_index(opcodes, lo)
|
||||
new_hi = _map_index(opcodes, max(lo, hi - 1))
|
||||
@@ -938,6 +950,8 @@ class ProfileReport:
|
||||
repo: str | None = None
|
||||
repos: list[str] = field(default_factory=list)
|
||||
pinned_tag: str | None = None
|
||||
# Set when pinned_tag carries a pin_frozen reason rather than a tag name.
|
||||
pin_frozen: bool = False
|
||||
host: str | None = None
|
||||
pin: str | None = None
|
||||
pin_origin: str | None = None
|
||||
@@ -1090,7 +1104,7 @@ def select_views(
|
||||
continue
|
||||
seen.add((repo.host, repo.slug))
|
||||
views.append(RepoView(repo, pin, head, origin, field))
|
||||
return _follow_submodule(views, cache_dir, offline)
|
||||
return _follow_fork_base(_follow_submodule(views, cache_dir, offline), cache_dir, offline)
|
||||
|
||||
|
||||
def _follow_submodule(
|
||||
@@ -1130,6 +1144,34 @@ def _follow_submodule(
|
||||
return followed
|
||||
|
||||
|
||||
def _follow_fork_base(
|
||||
views: list[RepoView], cache_dir: str, offline: bool
|
||||
) -> list[RepoView]:
|
||||
"""An upstream the port is a fork of moves with what the port merged.
|
||||
|
||||
snes9x, kronos, np2kai and six others pinned upstream_commit on the merge
|
||||
base of upstream with the fork, the upstream code the port carries. Read
|
||||
against upstream's own HEAD, the bump moved it past anything the port
|
||||
built from. Outside one fork network the HEAD stays the reference.
|
||||
"""
|
||||
source = next((v for v in views if v.field == "source"), None)
|
||||
if source is None:
|
||||
return views
|
||||
followed: list[RepoView] = []
|
||||
for view in views:
|
||||
if view.field != "upstream" or view.pin == view.head or " via " in view.origin:
|
||||
followed.append(view)
|
||||
continue
|
||||
base = upstream.fork_base(
|
||||
view.repo, view.head, source.repo, source.head, cache_dir, offline
|
||||
)
|
||||
followed.append(
|
||||
replace(view, head=base, origin=f"{view.origin}, fork base of {source.repo.slug}")
|
||||
if base else view
|
||||
)
|
||||
return followed
|
||||
|
||||
|
||||
def declared_repositories(profile: dict) -> list[tuple[str, str, str]]:
|
||||
"""Every repository URL the profile declares, source before upstream.
|
||||
|
||||
@@ -1534,6 +1576,7 @@ def build_report(
|
||||
# its refs at code the build never had, so it is judged like a frozen tag:
|
||||
# against itself.
|
||||
frozen = str(profile.get("pin_frozen") or "").strip()
|
||||
report.pin_frozen = bool(frozen)
|
||||
report.pinned_tag = frozen or detect_pinned_tag(
|
||||
profile, views, cache_dir, offline
|
||||
)
|
||||
@@ -2056,10 +2099,11 @@ def format_report(report: ProfileReport, changed_only: bool = False) -> str:
|
||||
f" {report.pin[:7]} -> {report.head[:7]} pin: {report.pin_origin}"
|
||||
)
|
||||
if report.pinned_tag:
|
||||
lines.append(
|
||||
f" pinned to tag {report.pinned_tag}: checked against its own "
|
||||
"revision, not against HEAD"
|
||||
held = (
|
||||
f"pin held ({report.pinned_tag})" if report.pin_frozen
|
||||
else f"pinned to tag {report.pinned_tag}"
|
||||
)
|
||||
lines.append(f" {held}: checked against its own revision, not against HEAD")
|
||||
elif report.pin and report.pin == report.head:
|
||||
lines.append(" pin is HEAD: checked for self-consistency")
|
||||
if report.upstream_tag and not report.pinned_tag:
|
||||
@@ -2179,20 +2223,16 @@ def check_version(
|
||||
declared = str(profile.get("core_version") or "")
|
||||
if not declared:
|
||||
return None
|
||||
tags = upstream.list_tags(repo, cache_dir, offline)
|
||||
release = upstream.latest_release(repo, cache_dir, offline)
|
||||
matches = declared in tags
|
||||
commit = (
|
||||
upstream.resolve_tag_commit(repo, declared, cache_dir, offline)
|
||||
if matches
|
||||
else None
|
||||
)
|
||||
# Asked by name: the first page of tags misses a release that a project
|
||||
# publishing nightlies has pushed past a hundred newer tags.
|
||||
commit = upstream.tag_commit(repo, declared, cache_dir, offline)
|
||||
return VersionReport(
|
||||
declared,
|
||||
tags[0] if tags else None,
|
||||
upstream.newest_tag(repo, cache_dir, offline),
|
||||
release.tag if release else None,
|
||||
release.date if release else None,
|
||||
matches,
|
||||
commit is not None,
|
||||
commit,
|
||||
)
|
||||
|
||||
@@ -2288,23 +2328,95 @@ def declared_hashes(profile: dict) -> set[str]:
|
||||
return hashes
|
||||
|
||||
|
||||
# Sources where a filename is a string. Outside a literal or a comment,
|
||||
# `EE.bios` is a config key and `psm_s.pal` a struct field (pcee2).
|
||||
CODE_SUFFIXES = (
|
||||
".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inc", ".inl",
|
||||
".m", ".mm", ".rs", ".java", ".kt", ".cs", ".swift",
|
||||
)
|
||||
_PRINTF_SPEC_RE = re.compile(
|
||||
r"[-+ #0]*(?:\d+|\*)?(?:\.\d+)?(?:hh|h|ll|l|z|j|t|L)?[diouxXsScpfFeEgGaA]"
|
||||
)
|
||||
_TITLE_WORD_RE = re.compile(r"[A-Z0-9][\w.()&'+-]*")
|
||||
|
||||
|
||||
def _string_literal(line: str, index: int) -> tuple[int, int] | None:
|
||||
"""Bounds of the double-quoted literal holding index, None outside one."""
|
||||
start = None
|
||||
position = 0
|
||||
while position < len(line):
|
||||
char = line[position]
|
||||
if char == "\\" and start is not None:
|
||||
position += 2
|
||||
continue
|
||||
if char == '"':
|
||||
if start is None:
|
||||
start = position + 1
|
||||
else:
|
||||
if start <= index < position:
|
||||
return start, position
|
||||
start = None
|
||||
position += 1
|
||||
return None
|
||||
|
||||
|
||||
def _in_comment(line: str, index: int) -> bool:
|
||||
before = line[:index]
|
||||
return "//" in before or "/*" in before or line.lstrip().startswith(("*", "#"))
|
||||
|
||||
|
||||
def file_literal(line: str, start: int, end: int, code: bool) -> str | None:
|
||||
"""The filename a match stands for, None when it names no file.
|
||||
|
||||
A printf conversion glued to the name is not part of it: sdl2trs writes
|
||||
`"%slevel2.rom"` with ROM_PATH in front. A name with spaces is read
|
||||
whole: virtualjaguar opens `"Jaguar CD BIOS.rom"`, and the match alone
|
||||
was `BIOS.rom`. Such a name is written as a title, so `"Please insert
|
||||
bios.bin"` and `"Packing patches.zip"` still yield the file alone. In
|
||||
a code file a match outside any string or comment is an identifier and
|
||||
names nothing.
|
||||
"""
|
||||
name = line[start:end]
|
||||
if start and line[start - 1] == "%":
|
||||
spec = _PRINTF_SPEC_RE.match(name)
|
||||
if spec:
|
||||
name = name[spec.end():]
|
||||
literal = _string_literal(line, start)
|
||||
if literal is None:
|
||||
if code and not _in_comment(line, start):
|
||||
return None
|
||||
elif literal[1] == end and line[start - 1:start] == " ":
|
||||
words = line[literal[0]:start].split() + [name]
|
||||
if all(_TITLE_WORD_RE.fullmatch(word) for word in words):
|
||||
return line[literal[0]:end]
|
||||
base = os.path.basename(name)
|
||||
# `".bin.hex.iii.rom"` (sdl2trs) lists extensions, it names no file.
|
||||
if base.startswith(".") or not _FILE_RE.fullmatch(base):
|
||||
return None
|
||||
return base
|
||||
|
||||
|
||||
def detect_new_files(
|
||||
head_lines: list[str], known: set[str], cap: int = 25
|
||||
head_lines: list[str], known: set[str], cap: int = 25, path: str = ""
|
||||
) -> tuple[list[tuple[int, str]], int]:
|
||||
"""Filename literals at HEAD the profile does not declare."""
|
||||
found: list[tuple[int, str]] = []
|
||||
seen: set[str] = set()
|
||||
elided = 0
|
||||
code = path.lower().endswith(CODE_SUFFIXES)
|
||||
for number, line in enumerate(head_lines, start=1):
|
||||
for match in _FILE_RE.findall(line):
|
||||
name = os.path.basename(match).casefold()
|
||||
for match in _FILE_RE.finditer(line):
|
||||
literal = file_literal(line, match.start(), match.end(), code)
|
||||
if literal is None:
|
||||
continue
|
||||
name = literal.casefold()
|
||||
if name in known or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if len(found) >= cap:
|
||||
elided += 1
|
||||
continue
|
||||
found.append((number, os.path.basename(match)))
|
||||
found.append((number, literal))
|
||||
return found, elided
|
||||
|
||||
|
||||
@@ -2543,16 +2655,66 @@ def _run_after_moves(
|
||||
return f"{new_path or parts[0].path}:{','.join(ranges)}"
|
||||
|
||||
|
||||
def _unique_line(
|
||||
lines: list[str], target: str, window: tuple[int, int]
|
||||
) -> int | None:
|
||||
"""The one line of the window carrying target, None when not exactly one."""
|
||||
hits = [
|
||||
i for i in range(window[0], min(window[1], len(lines) - 1) + 1)
|
||||
if target and target in lines[i]
|
||||
]
|
||||
return hits[0] if len(hits) == 1 else None
|
||||
|
||||
|
||||
def scalar_windows(
|
||||
text: str, document: object
|
||||
) -> dict[tuple[int, object], tuple[int, int]]:
|
||||
"""Physical lines each scalar spans, keyed by its holder and key.
|
||||
|
||||
The same sentence can sit in two fields: emucorex cites
|
||||
CMakeLists.txt:706 in notes and in exclusion_note, on identical lines.
|
||||
Searched over the whole file neither occurrence was unique, and only
|
||||
the node's own lines tell them apart.
|
||||
"""
|
||||
try:
|
||||
root = yaml.compose(text, Loader=yaml.SafeLoader)
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
windows: dict[tuple[int, object], tuple[int, int]] = {}
|
||||
|
||||
def walk(value: object, node: yaml.Node | None) -> None:
|
||||
if isinstance(value, dict) and isinstance(node, yaml.MappingNode):
|
||||
by_key = {str(k.value): v for k, v in node.value}
|
||||
for key, item in value.items():
|
||||
child = by_key.get(str(key))
|
||||
if child is None:
|
||||
continue
|
||||
windows[(id(value), key)] = (
|
||||
child.start_mark.line, child.end_mark.line
|
||||
)
|
||||
walk(item, child)
|
||||
elif isinstance(value, list) and isinstance(node, yaml.SequenceNode):
|
||||
for index, (item, child) in enumerate(zip(value, node.value)):
|
||||
windows[(id(value), index)] = (
|
||||
child.start_mark.line, child.end_mark.line
|
||||
)
|
||||
walk(item, child)
|
||||
|
||||
walk(document, root)
|
||||
return windows
|
||||
|
||||
|
||||
def _apply_prose_edits(
|
||||
text: str, jobs: list[tuple[Citation, dict]]
|
||||
text: str, jobs: list[tuple[Citation, dict]], document: object = None
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Rewrite prose citation tokens in place, nothing else.
|
||||
|
||||
Each run is located as the unique physical line carrying the scalar line
|
||||
it sits on. Two candidate lines, or none, means the write cannot be
|
||||
proved right, so the run is left alone and reported. All replacements of
|
||||
one line are applied together, right to left, so the spans measured on
|
||||
the original text stay valid.
|
||||
Each scalar line a run touches is located as the unique physical line
|
||||
carrying it. Two candidate lines, or none, means the write cannot be
|
||||
proved right, so the run is left alone and reported with the reason. A
|
||||
comment run is rewritten on its own physical line, once the tokens there
|
||||
read as collected. All replacements of one line are applied together,
|
||||
right to left, so the spans measured on the original text stay valid.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
trailing = "\n" if text.endswith("\n") else ""
|
||||
@@ -2561,17 +2723,10 @@ def _apply_prose_edits(
|
||||
holders: dict[tuple[int, object], tuple] = {}
|
||||
applied: list[str] = []
|
||||
left: list[str] = []
|
||||
windows = scalar_windows(text, document) if document is not None else {}
|
||||
whole = (0, len(lines) - 1)
|
||||
|
||||
for citation, moves in jobs:
|
||||
if citation.wrapped or citation.in_comment:
|
||||
where = "in a comment" if citation.in_comment else "wrapped across lines"
|
||||
left.append(f"{citation.field}: {citation.ref} ({where})")
|
||||
continue
|
||||
value = str(citation.holder[citation.key])
|
||||
value_lines = value.splitlines()
|
||||
if citation.line >= len(value_lines):
|
||||
left.append(f"{citation.field}: {citation.ref} (scalar changed)")
|
||||
continue
|
||||
replacements = []
|
||||
for index, (start, end, _) in moves.items():
|
||||
lo, hi = citation.spans[index]
|
||||
@@ -2581,13 +2736,65 @@ def _apply_prose_edits(
|
||||
new_path = next((m[2] for m in moves.values() if m[2]), None)
|
||||
if new_path:
|
||||
replacements.append((*citation.path_span, new_path))
|
||||
target = value_lines[citation.line]
|
||||
hits = [i for i, line in enumerate(lines) if target and target in line]
|
||||
if len(hits) == 1:
|
||||
offset = lines[hits[0]].index(target)
|
||||
file_edits.setdefault(hits[0], []).extend(
|
||||
(offset + lo, offset + hi, new) for lo, hi, new in replacements
|
||||
recaled = _run_after_moves(citation.parts, moves, citation.ref)
|
||||
|
||||
if citation.in_comment:
|
||||
# The comment is read again from the text being written: a run
|
||||
# whose tokens no longer sit at the collected offsets is left.
|
||||
line = lines[citation.line] if citation.line < len(lines) else ""
|
||||
expected = [p.raw for p in citation.parts] + [citation.ref.split(":", 1)[0]]
|
||||
spans = list(citation.spans) + [citation.path_span]
|
||||
if any(line[lo:hi] != token for (lo, hi), token in zip(spans, expected)):
|
||||
left.append(f"{citation.field}: {citation.ref} (comment changed)")
|
||||
continue
|
||||
file_edits.setdefault(citation.line, []).extend(replacements)
|
||||
applied.append(f"{citation.field}: {citation.ref} -> {recaled}")
|
||||
continue
|
||||
|
||||
value = str(citation.holder[citation.key])
|
||||
value_lines = value.splitlines()
|
||||
last = citation.line + (1 if citation.wrapped else 0)
|
||||
if last >= len(value_lines):
|
||||
left.append(f"{citation.field}: {citation.ref} (scalar changed)")
|
||||
continue
|
||||
# A wrapped run puts its spans back on the two scalar lines it
|
||||
# joined: playdiaemu wrote `content.rs` and `:111-199` one line apart.
|
||||
by_line: dict[int, list[tuple[int, int, str]]] = {}
|
||||
straddles = False
|
||||
for lo, hi, new in replacements:
|
||||
if citation.wrapped and lo >= citation.joined_at:
|
||||
following = value_lines[last]
|
||||
indent = len(following) - len(following.lstrip())
|
||||
shift = indent - citation.joined_at
|
||||
by_line.setdefault(last, []).append((lo + shift, hi + shift, new))
|
||||
elif citation.wrapped and hi > citation.joined_at:
|
||||
straddles = True
|
||||
else:
|
||||
by_line.setdefault(citation.line, []).append((lo, hi, new))
|
||||
if straddles:
|
||||
left.append(f"{citation.field}: {citation.ref} (a token spans the line break)")
|
||||
continue
|
||||
window = windows.get((id(citation.holder), citation.key), whole)
|
||||
located = {
|
||||
number: _unique_line(lines, value_lines[number], window)
|
||||
for number in by_line
|
||||
}
|
||||
if citation.wrapped and (
|
||||
None in located.values()
|
||||
or (len(located) == 2 and located[last] != located[citation.line] + 1)
|
||||
):
|
||||
left.append(
|
||||
f"{citation.field}: {citation.ref} "
|
||||
"(wrapped, and its two lines are not found together in the file)"
|
||||
)
|
||||
continue
|
||||
if None not in located.values():
|
||||
for number, edits in by_line.items():
|
||||
hit = located[number]
|
||||
offset = lines[hit].index(value_lines[number])
|
||||
file_edits.setdefault(hit, []).extend(
|
||||
(offset + lo, offset + hi, new) for lo, hi, new in edits
|
||||
)
|
||||
else:
|
||||
# A folded scalar has no physical line carrying its logical one.
|
||||
# The run itself is a single word, which folding never breaks:
|
||||
@@ -2599,31 +2806,31 @@ def _apply_prose_edits(
|
||||
rf"(?<![\w/.-]){re.escape(citation.ref)}(?![\w-])"
|
||||
)
|
||||
run_hits = [
|
||||
i for i, line in enumerate(lines) if pattern.search(line)
|
||||
i for i in range(window[0], min(window[1], len(lines) - 1) + 1)
|
||||
if pattern.search(lines[i])
|
||||
]
|
||||
if (
|
||||
len(run_hits) != 1
|
||||
or len(pattern.findall(lines[run_hits[0]])) != 1
|
||||
):
|
||||
carriers = sum(
|
||||
1 for line in lines[window[0]:window[1] + 1]
|
||||
if value_lines[citation.line] and value_lines[citation.line] in line
|
||||
)
|
||||
left.append(
|
||||
f"{citation.field}: {citation.ref} "
|
||||
f"({len(hits)} lines carry its sentence)"
|
||||
f"({carriers} lines carry its sentence)"
|
||||
)
|
||||
continue
|
||||
low = pattern.search(lines[run_hits[0]]).start()
|
||||
file_edits.setdefault(run_hits[0], []).append(
|
||||
(low, low + len(citation.ref),
|
||||
_run_after_moves(citation.parts, moves, citation.ref))
|
||||
(low, low + len(citation.ref), recaled)
|
||||
)
|
||||
slot = (id(citation.holder), citation.key)
|
||||
holders[slot] = (citation.holder, citation.key)
|
||||
value_edits.setdefault(slot, {}).setdefault(citation.line, []).extend(
|
||||
replacements
|
||||
)
|
||||
applied.append(
|
||||
f"{citation.field}: {citation.ref} -> "
|
||||
f"{_run_after_moves(citation.parts, moves, citation.ref)}"
|
||||
)
|
||||
for number, edits in by_line.items():
|
||||
value_edits.setdefault(slot, {}).setdefault(number, []).extend(edits)
|
||||
applied.append(f"{citation.field}: {citation.ref} -> {recaled}")
|
||||
|
||||
for number, replacements in file_edits.items():
|
||||
lines[number] = _replace_spans(lines[number], replacements)
|
||||
@@ -2648,31 +2855,54 @@ def _replace_spans(line: str, replacements: list[tuple[int, int, str]]) -> str:
|
||||
return line
|
||||
|
||||
|
||||
def blocks_the_pin(report: ProfileReport, accept_changed: bool = False) -> int:
|
||||
"""What would keep source_commit where it is after a recale.
|
||||
def _part_moves(part: PartResult, movable: tuple[str, ...]) -> bool:
|
||||
"""Whether a part has a new location the recale would write."""
|
||||
return (
|
||||
part.status in movable
|
||||
and part.start is not None
|
||||
and ((part.start, part.end) != (part.part.start, part.part.end)
|
||||
or bool(part.new_path))
|
||||
)
|
||||
|
||||
|
||||
def pin_blockers(report: ProfileReport, accept_changed: bool = False) -> list[str]:
|
||||
"""What would keep source_commit where it is after a recale, named.
|
||||
|
||||
Two kinds. A ref the writer never regenerates, annotated or under a mode
|
||||
key, whose parts have moved. And a prose run whose tokens cannot be
|
||||
located well enough to rewrite, which stays describing the pinned
|
||||
revision whatever else moves. Either one makes bump_commit refuse, so
|
||||
either one has to stop the recale as well.
|
||||
key, whose parts have moved. And a prose run whose parts disagree on
|
||||
where its file went, which cannot be written without guessing. Either
|
||||
one makes bump_commit refuse, so either one has to stop the recale as
|
||||
well, and the caller says which.
|
||||
"""
|
||||
movable = REBASE_STATUSES + (("CHANGED",) if accept_changed else ())
|
||||
blocked = pending_recale(report, accept_changed)
|
||||
blockers: list[str] = []
|
||||
for entry in report.entries or []:
|
||||
if entry.kind != "prose" or _prose_moves(entry, movable):
|
||||
if entry.kind == "prose":
|
||||
if not _prose_moves(entry, movable) and any(
|
||||
_part_moves(part, movable) for part in entry.parts
|
||||
):
|
||||
blockers.append(
|
||||
f"{entry.field}: {entry.source_ref} "
|
||||
"(its parts do not agree on one new file)"
|
||||
)
|
||||
continue
|
||||
if any(
|
||||
if not any(
|
||||
part.status in movable
|
||||
and part.start is not None
|
||||
and (
|
||||
(part.start, part.end) != (part.part.start, part.part.end)
|
||||
or part.new_path
|
||||
)
|
||||
and _rendered_part(part) != _original_part(part)
|
||||
for part in entry.parts
|
||||
):
|
||||
blocked += 1
|
||||
return blocked
|
||||
continue
|
||||
if entry.name.endswith("]"):
|
||||
blockers.append(
|
||||
f"{entry.name}: {entry.source_ref} (under a mode key, moved by hand)"
|
||||
)
|
||||
elif not _is_rewritable(entry.source_ref):
|
||||
blockers.append(
|
||||
f"{entry.name}: {entry.source_ref} "
|
||||
"(annotated, a rewrite would drop the annotation)"
|
||||
)
|
||||
return blockers
|
||||
|
||||
|
||||
def _view_role(view: RepoView) -> str:
|
||||
@@ -2755,17 +2985,20 @@ def rebase_refs(
|
||||
blocking = [s for s in REVIEW_STATUSES if s not in statuses]
|
||||
if any((report.counts or {}).get(s) for s in blocking):
|
||||
return []
|
||||
if blocks_the_pin(report, accept_changed):
|
||||
blockers = pin_blockers(report, accept_changed)
|
||||
if blockers:
|
||||
# Something here will not move: an annotated ref, one under a mode
|
||||
# key, or a prose run this pass cannot rewrite without guessing.
|
||||
# bump_commit will refuse while it stands, and recaling the rest
|
||||
# would leave the profile describing two revisions at once. All or
|
||||
# nothing means the pin too, not only the refs.
|
||||
_refuse_recale(path, blockers)
|
||||
return []
|
||||
text = path.read_text(encoding="utf-8")
|
||||
document = yaml.safe_load(text)
|
||||
citations = collect_citations(document, text)
|
||||
applied: list[str] = []
|
||||
refused: list[str] = []
|
||||
cursor = 0
|
||||
pos = 0
|
||||
prose_jobs: list[tuple[Citation, dict]] = []
|
||||
@@ -2773,6 +3006,11 @@ def rebase_refs(
|
||||
for entry in report.entries:
|
||||
citation, pos = _match_citation(citations, pos, entry)
|
||||
if citation is None:
|
||||
if any(_part_moves(part, statuses) for part in entry.parts):
|
||||
refused.append(
|
||||
f"{entry.field or entry.name}: {entry.source_ref} "
|
||||
"(the profile no longer carries it as reported)"
|
||||
)
|
||||
continue
|
||||
if entry.kind == "prose":
|
||||
moves = _prose_moves(entry, statuses)
|
||||
@@ -2783,6 +3021,15 @@ def rebase_refs(
|
||||
# The ref lives under a mode key, not on a source_ref line. The
|
||||
# entry name check also holds when the report and the document
|
||||
# disagree: a stale report must not touch a line it never read.
|
||||
if any(
|
||||
part.status in statuses
|
||||
and part.start is not None
|
||||
and _rendered_part(part) != _original_part(part)
|
||||
for part in entry.parts
|
||||
):
|
||||
refused.append(
|
||||
f"{entry.name}: {entry.source_ref} (under a mode key, moved by hand)"
|
||||
)
|
||||
continue
|
||||
if not _is_rewritable(entry.source_ref):
|
||||
# Rewriting would drop the author's annotations. Still consume
|
||||
@@ -2810,13 +3057,25 @@ def rebase_refs(
|
||||
citation.holder[citation.key] = new_ref
|
||||
applied.append(f"{entry.source_ref} -> {new_ref}")
|
||||
|
||||
text, prose_applied, _ = _apply_prose_edits(text, prose_jobs)
|
||||
text, prose_applied, left = _apply_prose_edits(text, prose_jobs, document)
|
||||
refused.extend(left)
|
||||
if refused:
|
||||
# A citation left on the pinned lines holds the pin, so writing the
|
||||
# others would leave the profile describing two revisions.
|
||||
_refuse_recale(path, refused)
|
||||
return []
|
||||
applied.extend(prose_applied)
|
||||
if applied:
|
||||
apply_edit(path, text, document)
|
||||
return applied
|
||||
|
||||
|
||||
def _refuse_recale(path: Path, reasons: list[str]) -> None:
|
||||
"""Name each citation that keeps a recale from being written."""
|
||||
for reason in reasons:
|
||||
print(f"{path.stem}: not recaled, cannot move {reason}", file=sys.stderr)
|
||||
|
||||
|
||||
def _citation_key(ref: str) -> str:
|
||||
"""A citation compared without the spacing the author chose.
|
||||
|
||||
@@ -2846,21 +3105,24 @@ def pending_recale(
|
||||
document = yaml.safe_load(text) if text is not None else None
|
||||
# A ref the writer regenerates is pending only while the file still
|
||||
# carries it as written: a bump run without the recale left lrps2's
|
||||
# SHIFTED refs on the old lines under the new pin.
|
||||
refs_left = (
|
||||
{c.ref for c in collect_citations(document) if c.kind == "ref"}
|
||||
if document is not None else set()
|
||||
)
|
||||
# SHIFTED refs on the old lines under the new pin. It is looked up at
|
||||
# its own field, never by its text alone: tr1x moved cut2 508->531 and
|
||||
# cut3 531->552, and cut3 read as pending on cut2's new value.
|
||||
refs_left: dict[tuple[str, str], int] = {}
|
||||
if document is not None:
|
||||
for citation in collect_citations(document):
|
||||
if citation.kind == "ref":
|
||||
for slot in ((citation.field, citation.ref), ("", citation.ref)):
|
||||
refs_left[slot] = refs_left.get(slot, 0) + 1
|
||||
pending = 0
|
||||
for entry in report.entries or []:
|
||||
if entry.kind == "prose":
|
||||
continue
|
||||
if (
|
||||
_is_rewritable(entry.source_ref)
|
||||
and not entry.name.endswith("]")
|
||||
and entry.source_ref not in refs_left
|
||||
):
|
||||
continue
|
||||
if _is_rewritable(entry.source_ref) and not entry.name.endswith("]"):
|
||||
slot = (entry.field, entry.source_ref)
|
||||
if not refs_left.get(slot):
|
||||
continue
|
||||
refs_left[slot] -= 1
|
||||
pending += sum(
|
||||
1
|
||||
for part in entry.parts
|
||||
@@ -2927,6 +3189,16 @@ def bump_commit(
|
||||
if pending_recale(report, accept_changed, text):
|
||||
return False
|
||||
document = yaml.safe_load(text)
|
||||
if document.get("built_by"):
|
||||
# The pin is the commit a platform builds (recalbox gsplus.mk, the
|
||||
# mame0258 its package fetches), not a point on a moving branch:
|
||||
# HEAD is a build nobody ships under this name.
|
||||
print(
|
||||
f"{path.stem}: pinned to the build {', '.join(document['built_by'])} "
|
||||
"ships, advanced by hand",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if any(isinstance(document.get(f), dict) for f in ("source_commit", "upstream_commit")):
|
||||
# A pin per build mode names one revision per repository, and the
|
||||
# report heads only the primary one: rewriting str(dict) was a
|
||||
@@ -3197,8 +3469,31 @@ def _realign_part(
|
||||
"""
|
||||
if is_external_citation(part.path):
|
||||
return None
|
||||
# The path as written decides first, as attribution does: a prefix is
|
||||
# stripped only when no declared repository carries the path itself.
|
||||
# hatarib's hatari/src/tos.c lives in hatariB, which did not move, and
|
||||
# stripping the prefix read it as hatari/hatari's src/tos.c instead.
|
||||
# Between two repositories that both carry the path, the one declared
|
||||
# first owns it, as in attribution: freej2me-plus and hex007/freej2me
|
||||
# both hold src/libretro/, and the citations describe the fork.
|
||||
literal = [
|
||||
(repo, written, current) for repo, written, current in pairs
|
||||
if upstream.fetch_file(repo, written, part.path, cache_dir, offline) is not None
|
||||
]
|
||||
moved = {repo for repo, _written, _current in pairs}
|
||||
order = [repo for repo, _pin in pinned]
|
||||
first_moved = min(
|
||||
(order.index(repo) for repo, _w, _c in literal if repo in order),
|
||||
default=len(order),
|
||||
)
|
||||
if any(
|
||||
upstream.fetch_file(repo, pin, part.path, cache_dir, offline) is not None
|
||||
for index, (repo, pin) in enumerate(pinned)
|
||||
if repo not in moved and (not literal or index < first_moved)
|
||||
):
|
||||
return None
|
||||
unclear = None
|
||||
for repo, written, current in pairs:
|
||||
for repo, written, current in literal or pairs:
|
||||
state, payload = _resolve_at(repo, written, part.path, cache_dir, offline)
|
||||
if state == "unclear":
|
||||
unclear = payload
|
||||
@@ -3221,7 +3516,6 @@ def _realign_part(
|
||||
return "skip", f"{part.path}:{part.start} {anchored.status.lower()}"
|
||||
if unclear:
|
||||
return "skip", f"{part.path}: {unclear}"
|
||||
moved = {repo for repo, _written, _current in pairs}
|
||||
if any(
|
||||
upstream.fetch_file(repo, pin, part.path, cache_dir, offline) is not None
|
||||
for repo, pin in pinned
|
||||
@@ -3444,7 +3738,7 @@ def realign_prose(
|
||||
for citation, moves in jobs
|
||||
)
|
||||
return messages
|
||||
new_text, applied, left = _apply_prose_edits(text, jobs)
|
||||
new_text, applied, left = _apply_prose_edits(text, jobs, document)
|
||||
messages.extend(applied)
|
||||
messages.extend(f"left in place: {item}" for item in left)
|
||||
if applied:
|
||||
@@ -3452,31 +3746,37 @@ def realign_prose(
|
||||
return messages
|
||||
|
||||
|
||||
def _refuse_dirty_tree(emulators_dir: str) -> None:
|
||||
"""Writes need a clean profile tree to roll back to."""
|
||||
def _refuse_dirty_tree(emulators_dir: str, names: list[str]) -> None:
|
||||
"""Writes need a clean copy of each profile they touch to roll back to.
|
||||
|
||||
Only the selected profiles count: a pass writing one profile after
|
||||
another used to refuse every profile after the first it wrote.
|
||||
"""
|
||||
try:
|
||||
dirty = emulators_dir_is_dirty(emulators_dir)
|
||||
dirty = emulators_dir_is_dirty(emulators_dir, names)
|
||||
except RuntimeError as e:
|
||||
print(f"{e}. Pass --force to write anyway.", file=sys.stderr)
|
||||
raise SystemExit(1) from e
|
||||
if dirty:
|
||||
print(
|
||||
f"{emulators_dir} carries uncommitted changes. "
|
||||
"Commit them first or pass --force.",
|
||||
f"{emulators_dir} carries uncommitted changes to the selected "
|
||||
"profiles. Commit them first or pass --force.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def emulators_dir_is_dirty(emulators_dir: str) -> bool:
|
||||
def emulators_dir_is_dirty(emulators_dir: str, names: list[str] | None = None) -> bool:
|
||||
"""True when the profile directory carries uncommitted changes.
|
||||
|
||||
Git is asked from inside the directory, so a profile tree that lives in
|
||||
another repository answers for itself. A directory git cannot read is
|
||||
never reported clean: the writes have nothing to roll back to.
|
||||
``names`` narrows the question to those profiles. Git is asked from
|
||||
inside the directory, so a profile tree that lives in another
|
||||
repository answers for itself. A directory git cannot read is never
|
||||
reported clean: the writes have nothing to roll back to.
|
||||
"""
|
||||
paths = [f"{name}.yml" for name in names] if names is not None else ["."]
|
||||
result = subprocess.run(
|
||||
["git", "-C", emulators_dir, "status", "--porcelain", "--", "."],
|
||||
["git", "-C", emulators_dir, "status", "--porcelain", "--", *paths],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
@@ -3846,17 +4146,34 @@ def _apply_writes(
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
for line in applied:
|
||||
print(f"{name}: {recale}{line}")
|
||||
messages = [f"{name}: {recale}{line}" for line in applied]
|
||||
if moved:
|
||||
# Each pin the write changes, as written: a bump that only moved
|
||||
# upstream_commit announced the source_commit it already held.
|
||||
after = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
before = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
for pin in pins_changed(before, after):
|
||||
print(f"{name}: {bumped}{pin} -> {str(after[pin])[:7]}")
|
||||
messages += [
|
||||
f"{name}: {bumped}{pin} -> {str(after[pin])[:7]}"
|
||||
for pin in pins_changed(before, after)
|
||||
]
|
||||
if not args.dry_run and (applied or moved):
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(target.read_bytes())
|
||||
# The guard above read the prose under the pins the profile held.
|
||||
# A bump that moves upstream_commit can leave prose the report
|
||||
# judged in the fork under the new upstream pin (dolphin-mmjr2,
|
||||
# hatari, xenios): read it again on what was written.
|
||||
left = realign_prose(path, args.cache_dir, args.offline, dry_run=True)
|
||||
if moved and left:
|
||||
path.write_bytes(original)
|
||||
print(
|
||||
f"{name}: the bump would leave {len(left)} prose citation(s) "
|
||||
"under an older pin, nothing was written. Read them first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
for message in messages:
|
||||
print(message)
|
||||
|
||||
|
||||
def _cited_paths(report: ProfileReport) -> list[str]:
|
||||
@@ -3906,10 +4223,12 @@ def _detect(
|
||||
or []
|
||||
)
|
||||
if args.detect_new_files:
|
||||
found, elided = detect_new_files(head_lines, names)
|
||||
found, elided = detect_new_files(head_lines, names, path=path)
|
||||
at_pin = {
|
||||
name.casefold()
|
||||
for _, name in detect_new_files(pin_lines, names, cap=sys.maxsize)[0]
|
||||
for _, name in detect_new_files(
|
||||
pin_lines, names, cap=sys.maxsize, path=path
|
||||
)[0]
|
||||
}
|
||||
new_files.extend(
|
||||
{"path": path, "line": number, "name": name,
|
||||
@@ -4217,7 +4536,7 @@ def main() -> None:
|
||||
or args.realign_prose or args.attribute_refs
|
||||
)
|
||||
if writes and not args.dry_run and not args.force:
|
||||
_refuse_dirty_tree(args.emulators_dir)
|
||||
_refuse_dirty_tree(args.emulators_dir, list(selected))
|
||||
|
||||
# A profile that could not be compared or written is not a profile with
|
||||
# nothing to review: it stays in every output and fails the run.
|
||||
|
||||
+27
-2
@@ -231,6 +231,14 @@ PROSE_CONT_RE = re.compile(r",(?P<range>\d+(?:-\d+)?)(?![\w-])(?!\.\d)")
|
||||
# A spaced continuation is accepted only for a range: `x.c:55-71, 80-147`
|
||||
# follows the source_ref convention, while `x.c:100, 200 files` is prose.
|
||||
PROSE_CONT_SPACED_RE = re.compile(r", +(?P<range>\d+-\d+)(?![\w-])(?!\.\d)")
|
||||
# A spaced single line joins the run only inside a list that closes the
|
||||
# citation: `bgmusic.c:29, 321)` and `.gitlab-ci.yml:110, 116, 139, 400`
|
||||
# end on a bracket, a semicolon, a sentence end or the line end, while in
|
||||
# `x.c:100, 200 files` a word follows and the number is prose.
|
||||
PROSE_CONT_LIST_RE = re.compile(
|
||||
r"(?:, +\d+(?:-\d+)?(?![\w-])(?!\.\d))+(?=[)\];]|\.(?!\w)|\s*$)"
|
||||
)
|
||||
_PROSE_LIST_RANGE_RE = re.compile(r"\d+(?:-\d+)?")
|
||||
# Words that read as prose before a filename. Anything else in project
|
||||
# position marks the citation external, the way `munt ROMInfo.cpp` does in
|
||||
# a source_ref: the profile does not declare that repository.
|
||||
@@ -321,7 +329,7 @@ def resolve_project_word(path: str, trees: Iterable[list[str] | None]) -> str |
|
||||
def _prose_runs(
|
||||
text: str, known: frozenset[str] | set[str] = frozenset()
|
||||
) -> Iterator[tuple[
|
||||
int, tuple[int, int], tuple[tuple[int, int], ...], tuple[RefPart, ...], str, bool
|
||||
int, tuple[int, int], tuple[tuple[int, int], ...], tuple[RefPart, ...], str, int
|
||||
]]:
|
||||
"""Citation runs inside one prose scalar.
|
||||
|
||||
@@ -330,6 +338,10 @@ def _prose_runs(
|
||||
`main.c:112,1624-1633` cites two ranges of one file, while `x.c:100, 200`
|
||||
is a citation followed by prose. A token carrying :// is a URL, whose
|
||||
host:port shape would otherwise pass for a citation.
|
||||
|
||||
The last item is 0 for a run held by one line. A run wrapped onto the
|
||||
next line gives the offset where that line's text starts in the
|
||||
joined line, so a writer can put each span back on its own line.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
for number, own in enumerate(lines):
|
||||
@@ -339,10 +351,12 @@ def _prose_runs(
|
||||
# read as the run's continuation, and a run reaching into it is wrapped.
|
||||
line = own.rstrip()
|
||||
boundary = len(line)
|
||||
joined_at = 0
|
||||
following = lines[number + 1].lstrip() if number + 1 < len(lines) else ""
|
||||
if PROSE_WRAPPED_RANGE_RE.match(following) and line[-1:].isalnum():
|
||||
# The break can fall before the colon too: jgenesis wraps
|
||||
# `.../src/config.rs` then `:76-78,95-99` on the next line.
|
||||
joined_at = len(line)
|
||||
line += following
|
||||
following = ""
|
||||
# A numbered list after a colon (abuse: `setup.cpp:` then `1. -datadir`)
|
||||
@@ -350,8 +364,10 @@ def _prose_runs(
|
||||
if not following[:1].isdigit() or PROSE_LIST_ITEM_RE.match(following):
|
||||
following = ""
|
||||
if following and line.endswith(":"):
|
||||
joined_at = len(line)
|
||||
line += following
|
||||
elif following and line.endswith(","):
|
||||
joined_at = len(line) + 1
|
||||
line += " " + following
|
||||
consumed = 0
|
||||
for match in PROSE_CITE_RE.finditer(line):
|
||||
@@ -391,6 +407,15 @@ def _prose_runs(
|
||||
end = match.end()
|
||||
while True:
|
||||
cont = PROSE_CONT_RE.match(line, end)
|
||||
if cont is None and (listed := PROSE_CONT_LIST_RE.match(line, end)):
|
||||
spans.extend(
|
||||
(item.start(), item.end())
|
||||
for item in _PROSE_LIST_RANGE_RE.finditer(
|
||||
line, end, listed.end()
|
||||
)
|
||||
)
|
||||
end = listed.end()
|
||||
break
|
||||
if cont is None:
|
||||
cont = PROSE_CONT_SPACED_RE.match(line, end)
|
||||
if cont is None:
|
||||
@@ -405,5 +430,5 @@ def _prose_runs(
|
||||
parts.append(RefPart(path, int(a), int(b or a), rng, role))
|
||||
yield (
|
||||
number, path_span, tuple(spans), tuple(parts),
|
||||
line[path_start:end], end > boundary,
|
||||
line[path_start:end], joined_at if end > boundary else 0,
|
||||
)
|
||||
+106
-1
@@ -614,6 +614,73 @@ def list_tags(repo: Repo, cache_dir: str, offline: bool = False) -> list[str]:
|
||||
return [t["name"] for t in payload if isinstance(t, dict) and t.get("name")]
|
||||
|
||||
|
||||
GITHUB_GRAPHQL = "https://api.github.com/graphql"
|
||||
_NEWEST_TAG_QUERY = (
|
||||
"query($owner:String!,$name:String!){repository(owner:$owner,name:$name)"
|
||||
"{refs(refPrefix:\"refs/tags/\",first:1,"
|
||||
"orderBy:{field:TAG_COMMIT_DATE,direction:DESC}){nodes{name}}}}"
|
||||
)
|
||||
|
||||
|
||||
def _github_graphql(
|
||||
query: str, variables: dict, cache_dir: str, offline: bool
|
||||
) -> object | None:
|
||||
"""A cached GitHub GraphQL answer, or None without a token.
|
||||
|
||||
GraphQL refuses anonymous calls, so an untokened run keeps the REST
|
||||
answer it already had.
|
||||
"""
|
||||
from common import github_token # noqa: PLC0415
|
||||
|
||||
body = json.dumps({"query": query, "variables": variables}, sort_keys=True)
|
||||
target = Path(cache_dir) / "_api" / f"{hashlib.sha256(body.encode()).hexdigest()}.json"
|
||||
if target.is_file():
|
||||
fresh = time.time() - target.stat().st_mtime <= MOVING_TTL
|
||||
if fresh or offline:
|
||||
return json.loads(target.read_text(encoding="utf-8"))
|
||||
token = github_token()
|
||||
if offline or not token:
|
||||
return None
|
||||
headers = _headers(GITHUB_GRAPHQL, accept_json=True)
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
GITHUB_GRAPHQL, data=body.encode(), headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise _http_failure(GITHUB_GRAPHQL, exc) from exc
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
||||
raise UpstreamError(f"{GITHUB_GRAPHQL}: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("errors"):
|
||||
return None
|
||||
write_cache(target, json.dumps(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def newest_tag(repo: Repo, cache_dir: str, offline: bool = False) -> str | None:
|
||||
"""The tag on the newest commit.
|
||||
|
||||
GitHub's REST listing sorts tags by name: nesoid lists 2.5 before 2.5-4,
|
||||
gargoyle stable-20090825 before 2026.1.1. GraphQL sorts by commit date.
|
||||
Elsewhere, or without a token, the forge's own first entry.
|
||||
"""
|
||||
if repo.family == "github":
|
||||
payload = _github_graphql(
|
||||
_NEWEST_TAG_QUERY, {"owner": repo.owner, "name": repo.name},
|
||||
cache_dir, offline,
|
||||
)
|
||||
try:
|
||||
nodes = payload["data"]["repository"]["refs"]["nodes"]
|
||||
except (TypeError, KeyError):
|
||||
nodes = None
|
||||
if isinstance(nodes, list):
|
||||
return nodes[0].get("name") if nodes else None
|
||||
tags = list_tags(repo, cache_dir, offline)
|
||||
return tags[0] if tags else None
|
||||
|
||||
|
||||
def resolve_tag_commit(
|
||||
repo: Repo, tag: str, cache_dir: str, offline: bool = False
|
||||
) -> str | None:
|
||||
@@ -630,6 +697,22 @@ def resolve_tag_commit(
|
||||
return None
|
||||
|
||||
|
||||
def _named_tag_commit(
|
||||
repo: Repo, tag: str, cache_dir: str, offline: bool
|
||||
) -> str | None:
|
||||
"""A GitLab or Forgejo tag's commit, asked for by name."""
|
||||
quoted = urllib.parse.quote(tag, safe="")
|
||||
if repo.family == "gitlab":
|
||||
url = f"{repo.api_base}/projects/{_project(repo)}/repository/tags/{quoted}"
|
||||
else:
|
||||
url = f"{repo.api_base}/repos/{repo.slug}/tags/{quoted}"
|
||||
payload = _api(url, cache_dir, offline, absent_max_age=MOVING_TTL)
|
||||
commit = payload.get("commit") if isinstance(payload, dict) else None
|
||||
if not isinstance(commit, dict):
|
||||
return None
|
||||
return commit.get("id") or commit.get("sha")
|
||||
|
||||
|
||||
def tag_commit(
|
||||
repo: Repo, tag: str, cache_dir: str, offline: bool = False
|
||||
) -> str | None:
|
||||
@@ -640,7 +723,9 @@ def tag_commit(
|
||||
An annotated tag points at a tag object, which is dereferenced.
|
||||
"""
|
||||
if repo.family != "github":
|
||||
return resolve_tag_commit(repo, tag, cache_dir, offline)
|
||||
return _named_tag_commit(repo, tag, cache_dir, offline) or resolve_tag_commit(
|
||||
repo, tag, cache_dir, offline
|
||||
)
|
||||
quoted = urllib.parse.quote(tag)
|
||||
# A tag pushed after the first lookup must be seen: the 404 expires,
|
||||
# the commit a tag names does not.
|
||||
@@ -755,6 +840,26 @@ def _commits_ahead(payload: object, family: str) -> int | None:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def fork_base(
|
||||
repo: Repo, sha: str, fork: Repo, fork_sha: str, cache_dir: str,
|
||||
offline: bool = False,
|
||||
) -> str | None:
|
||||
"""The commit of ``repo`` that ``fork`` at ``fork_sha`` has merged up to.
|
||||
|
||||
GitHub compares across a fork network and names the merge base. Two
|
||||
repositories outside one network answer 404, and None is returned.
|
||||
"""
|
||||
if repo.family != "github" or fork.family != "github":
|
||||
return None
|
||||
payload = _api(
|
||||
f"{repo.api_base}/repos/{repo.slug}/compare/"
|
||||
f"{sha}...{fork.owner}:{fork.name}:{fork_sha}",
|
||||
cache_dir, offline,
|
||||
)
|
||||
base = payload.get("merge_base_commit") if isinstance(payload, dict) else None
|
||||
return base.get("sha") if isinstance(base, dict) else None
|
||||
|
||||
|
||||
def ancestry(
|
||||
repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False
|
||||
) -> tuple[int, int] | None:
|
||||
|
||||
Reference in new issue
Block a user