"""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 ``--g``, 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: return isinstance(value, str) and value.strip().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()]