diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e238ed7e..d08faddb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -704,6 +704,16 @@ jobs: fetch-depth: 0 - name: Install lua run: sudo apt-get install -y --no-install-recommends lua5.4 + # The dependencies stated by the manifests inside packages' archives, + # read by tests/select_members.py. An archive is pinned by its sha256, + # so an entry never goes stale; a new key per descriptor set keeps the + # entries of the previous one and adds the archives it adds. + - name: Restore the archive manifests read before + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/select-manifests.json + key: select-manifests-${{ hashFiles('pkgs/**/*.lua') }} + restore-keys: select-manifests- # ── Selective member testing ────────────────────────────────────── # `mcpp test --workspace` builds every member (opencv, ffmpeg, …) and # dominates CI wall-clock, while a PR almost always touches one @@ -830,7 +840,7 @@ jobs: changed="" fi printf 'changed files vs %s:\n%s\n' "${base:-}" "$changed" - sel=""; pkgsel="" + sel=""; pkgsel=""; descriptors="" add() { case " $sel " in *" $1 "*) ;; *) sel="$sel $1" ;; esac; } while IFS= read -r f; do [ -n "$f" ] || continue @@ -856,15 +866,11 @@ jobs: if [ -d "tests/examples/$m" ] || [ -d "tests/mwrap/$m" ]; then add "$m"; fi ;; pkgs/*.lua|pkgs/*/*.lua) # A DELETED descriptor is in the diff but not on disk, and its - # CN urls are gone with it — nothing left to reach. + # CN urls are gone with it — nothing left to reach. The members + # it reaches are selected after the loop, all descriptors at + # once (tests/select_members.py). if [ -f "$f" ]; then pkgsel="$pkgsel $f"; fi - lib=$(basename "$f" .lua); lib=${lib##*.} - hit=0 - for mt in tests/examples/*/mcpp.toml tests/mwrap/*/mcpp.toml; do - [ -f "$mt" ] || continue - if grep -Fq "$lib" "$mt"; then add "$(basename "$(dirname "$mt")")"; hit=1; fi - done - [ "$hit" = 1 ] || echo "note: no workspace member exercises $f" ;; + descriptors="$descriptors $f" ;; # tools/ holds OFFLINE descriptor-generation and publishing # helpers (tools/compat-*/, tools/gtc/, publish_mcpp_index.sh). # Nothing under it is consumed by a package build: when one of @@ -890,6 +896,18 @@ jobs: done <> "$GITHUB_ENV" echo "selected members: ${sel:-}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a64386..f5ced3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ ### Changed +- **CI 按包身份选择受影响的成员。** `validate.yml` 的 select 曾在成员清单中查找描述符短名的 + 子串:`pkgs/o/os.lua` 选中 205 个成员中的 113 个(`boost-*`、`macos`、`cfg(os = ...)`、 + 注释里的 `exists`),而经 asio、curl 依赖 `compat.openssl` 的成员一个也未选中。 + `tests/select_members.py` 以 mcpp 的规则读取成员声明的 `(namespace, name)`,沿描述符的 + `deps` 与包归档内清单(按 tag 读 raw.githubusercontent.com;release 资产则读归档本身, + 并经 `path` 依赖到达同一归档的其他清单)陈述的依赖求传递闭包,选出依赖图含被修改身份的成员。 + 归档以 sha256 固定,已读结果由 `actions/cache` 保存。读不到的清单记为警告,由每周全量运行覆盖。 - **`compat.sycl-runtime` 在 Linux 上要求 `mcpp:c++-abi=libstdc++`。** `libsycl.so` 在 libstdc++ 上抛出 `sycl::exception`,而一个进程中的两套 C++ 运行时之间带类型的 `catch` 不会匹配 (mcpp-community/mcpp#838)。gcc 工具链,或 llvm 加 `gcc.libstdcxx`,满足该要求;其他构建 diff --git a/tests/descriptor_identities.lua b/tests/descriptor_identities.lua new file mode 100644 index 00000000..8e5e8ca9 --- /dev/null +++ b/tests/descriptor_identities.lua @@ -0,0 +1,76 @@ +-- Print what each descriptor declares about itself and about its neighbours: +-- +-- TAB TAB TAB ,... TAB # ... +-- +-- A dependency is a key of any `deps` table inside `package`, spelled as the +-- descriptor spells it (`compat.openssl`, `compat:openssl`, `zlib`); the +-- reader of this output (tests/select_members.py) resolves the spelling +-- against the identities printed here. +-- +-- The last column is where the rest of the package's dependencies are stated +-- when the descriptor does not state them: a package whose `mcpp` is not an +-- inline table builds from the manifest inside its archive (`mcpp = +-- "*/ld/mcpp.toml"`, or `mcpp.toml` at the archive's root when the field is +-- absent). One entry per version: the archive's GLOBAL url, `#`, and the +-- manifest's path inside it, prefixed with `?` when the field is absent -- +-- the archive may then hold no manifest at all (a package built by xpm, or +-- one whose install() writes it). A descriptor that does not load prints no line. +-- +-- Usage: lua5.4 tests/descriptor_identities.lua pkgs/*/*.lua + +function import(...) + return setmetatable({}, {__index = function() return function() end end}) +end + +local function collect(t, out, seen) + if seen[t] then return end + seen[t] = true + for k, v in pairs(t) do + if k == "deps" and type(v) == "table" then + for dk, dv in pairs(v) do + if type(dk) == "string" then out[dk] = true + elseif type(dv) == "string" then out[dv] = true end + end + end + if type(v) == "table" then collect(v, out, seen) end + end +end + +for i = 1, #arg do + package = nil + local chunk = loadfile(arg[i], "t") + if chunk then + -- `package = { ... }` is the first statement, so it is populated even + -- if a later top-level line fails under the stubbed `import`. + pcall(chunk) + local p = package + if type(p) == "table" and type(p.name) == "string" then + local deps = {} + collect(p, deps, {}) + local list = {} + for d in pairs(deps) do list[#list + 1] = d end + table.sort(list) + local manifests = {} + if type(p.mcpp) ~= "table" then + local inside = "?mcpp.toml" + if type(p.mcpp) == "string" then inside = (p.mcpp:gsub("^%*/", "")) end + local seen = {} + for _, versions in pairs(type(p.xpm) == "table" and p.xpm or {}) do + for _, entry in pairs(type(versions) == "table" and versions or {}) do + local url = type(entry) == "table" and entry.url + if type(url) == "table" then url = url.GLOBAL end + if type(url) == "string" and not seen[url] then + seen[url] = true + manifests[#manifests + 1] = url .. "#" .. inside + end + end + end + table.sort(manifests) + end + print(table.concat({ arg[i], + type(p.namespace) == "string" and p.namespace or "", + p.name, table.concat(list, ","), + table.concat(manifests, " ") }, "\t")) + end + end +end diff --git a/tests/select_members.py b/tests/select_members.py new file mode 100644 index 00000000..9d338a06 --- /dev/null +++ b/tests/select_members.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""The workspace members a change to some descriptors reaches. + +A descriptor is a package identity, `(namespace, name)`. A member is reached +when the dependency graph it declares contains a changed identity: its own +manifest names it, or names a package of this index that depends on it, at any +distance. Both ends are read as what they declare and matched by identity: + + * a member's manifest, as TOML, with mcpp's reading of nesting -- a table + naming a source (`path`, `version`, `git`, `workspace`) is a dependency, + any other table a namespace, and a bare key is in `mcpplibs`; + * a descriptor, through tests/descriptor_identities.lua: its identity, the + `deps` it states, and -- for a package that builds from the manifest in its + archive -- where that manifest is. The manifest is read at the archive's + ref from raw.githubusercontent.com, or from the archive itself when the + archive is a release asset rather than the repository's tree; its + dependencies, and those of the manifests it reaches by `path`, are the + package's remaining edges. An archive is pinned by its sha256, so what was + read once is kept in a cache file (`--cache`) and read again only for an + archive the cache does not hold. + +The selection this replaces searched each member's manifest for the +descriptor's short name as a substring: `pkgs/o/os.lua` selected 113 of 205 +members (`boost-*`, `macos`, `cfg(os = ...)`, `posix`), and `compat.openssl` +missed every member that reaches it through asio or curl. + +What it cannot see is a manifest it cannot read: an archive that is not on +GitHub, or a fetch that fails three times. Each is reported as a warning, and +the scheduled full run covers that edge, as it covers two changes that break +each other. + +Usage: select_members.py [--base ] [--cache ] ... + prints the selected member directory names on one line, space separated; + a descriptor not on disk is read at (a deletion); a descriptor no + member reaches is noted on stderr. +""" +from __future__ import annotations + +import argparse +import concurrent.futures +import io +import json +import posixpath +import re +import subprocess +import sys +import tarfile +import tempfile +import time +import tomllib +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_NAMESPACE = "mcpplibs" # mcpp: `cmdline = "0.0.2"` is (mcpplibs, cmdline) +SOURCE_KEYS = ("path", "version", "git", "workspace") # mcpp's spec-vs-namespace discriminator + +Identity = tuple[str, str] + + +# ── What a manifest declares ──────────────────────────────────────────────── + +def is_spec(value) -> bool: + if isinstance(value, str): + return True + return isinstance(value, dict) and any(k in value for k in SOURCE_KEYS) + + +def dependency_tables(manifest: dict): + """Every `*dependencies` table of a manifest: [dependencies], + [dev-dependencies], [build-dependencies], [workspace.dependencies], and + the same under [target.] and [feature-deps.].""" + for key, value in manifest.items(): + if not isinstance(value, dict): + continue + if key.endswith("dependencies"): + yield value + else: + yield from dependency_tables(value) + + +def declared(table: dict, namespace: str | None = None): + """(selector, spec) for every dependency a table declares. A selector is + the namespace path and the key joined by dots, as mcpp spells it.""" + for key, value in table.items(): + selector = key if namespace is None else f"{namespace}.{key}" + if is_spec(value): + yield selector, value + elif isinstance(value, dict): + yield from declared(value, selector) + + +def resolve(spelling: str, identities: set[Identity]) -> set[Identity]: + """A spelling of a dependency (`os`, `compat.openssl`, `compat:openssl@3`, + `mcpplibs.mwrap.ffmpeg`), as the identities of this index it can name.""" + spelling = spelling.split("@", 1)[0] + if ":" in spelling: + return {tuple(spelling.split(":", 1))} & identities + found = {(DEFAULT_NAMESPACE, spelling)} & identities + parts = spelling.split(".") + for i in range(1, len(parts)): + candidate = (".".join(parts[:i]), ".".join(parts[i:])) + if candidate in identities: + found.add(candidate) + return found + + +# ── What the index declares ───────────────────────────────────────────────── + +class Index: + def __init__(self, rows: list[str]): + self.identity_of: dict[str, Identity] = {} + self.deps: dict[Identity, set[str]] = {} + self.manifests: dict[Identity, set[str]] = {} + for row in rows: + path, ns, name, deps, manifests = (row.split("\t") + [""])[:5] + ident = (ns, name) + self.identity_of[path] = ident + self.deps.setdefault(ident, set()).update(d for d in deps.split(",") if d) + self.manifests.setdefault(ident, set()).update(m for m in manifests.split() if m) + self.identities = set(self.deps) + + +def dump(files: list[str], cwd: Path = ROOT) -> list[str]: + if not files: + return [] + out = subprocess.run(["lua5.4", str(ROOT / "tests/descriptor_identities.lua"), *files], + check=True, capture_output=True, text=True, cwd=cwd).stdout + return out.splitlines() + + +def deleted_identities(paths: list[str], base: str) -> dict[str, Identity]: + """Descriptors removed by the change, read as they were at `base`.""" + out = {} + with tempfile.TemporaryDirectory() as tmp: + for path in paths: + text = subprocess.run(["git", "show", f"{base}:{path}"], cwd=ROOT, + capture_output=True, text=True) + if text.returncode != 0: + continue + copy = Path(tmp) / Path(path).name + copy.write_text(text.stdout) + for row in dump([str(copy)], cwd=Path(tmp)): + _, ns, name = row.split("\t")[:3] + out[path] = (ns, name) + return out + + +# ── Manifests inside archives ─────────────────────────────────────────────── + +ARCHIVE = re.compile(r"https://github\.com/([^/]+)/([^/]+)/(?:" + r"archive/(?:refs/tags/)?(?P.+?)\.(?:tar\.gz|tgz|zip)" + r"|releases/download/(?P[^/]+)/)") + + +def raw_location(location: str) -> tuple[str, str] | None: + """`#` -> (the raw url of the archive's + ref, the manifest's path inside it), or None for an archive this cannot + address.""" + url, _, inside = location.partition("#") + m = ARCHIVE.match(url) + if not m: + return None + ref = m.group("archive") or m.group("release") + return f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/{ref}", inside + + +def fetch(url: str) -> bytes: + """The body at `url`; a 404 at once, any other failure after three tries.""" + for attempt in range(3): + try: + with urllib.request.urlopen(url, timeout=60) as response: + return response.read() + except urllib.error.HTTPError as e: + if e.code == 404 or attempt == 2: + raise + except OSError: + if attempt == 2: + raise + time.sleep(2 * (attempt + 1)) + raise AssertionError("unreachable") + + +def archive_files(data: bytes) -> dict[str, bytes]: + """The manifests of an archive, by path below its top-level directory.""" + out = {} + if data[:2] == b"PK": + with zipfile.ZipFile(io.BytesIO(data)) as z: + for name in z.namelist(): + if name.endswith("mcpp.toml") and "/" in name: + out[name.split("/", 1)[1]] = z.read(name) + return out + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as t: + for member in t: + if member.isfile() and member.name.endswith("mcpp.toml") and "/" in member.name: + out[member.name.split("/", 1)[1]] = t.extractfile(member).read() + return out + + +def merge(into: dict, more: dict) -> None: + for key, value in more.items(): + if isinstance(value, dict) and isinstance(into.get(key), dict): + merge(into[key], value) + else: + into[key] = value + + +def loads(text: str) -> dict: + """A manifest, as mcpp reads one: a table may be opened twice, and the + second opening adds to the first (TOML forbids it; published manifests -- + openkal-llvm-runtime 0.7.0 to 0.9.7 among them -- carry it).""" + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError: + pass + out: dict = {} + chunk: list[str] = [] + for line in text.splitlines(keepends=True) + ["[]"]: + if line.lstrip().startswith("[") and not line.lstrip().startswith("[["): + merge(out, tomllib.loads("".join(chunk))) + chunk = [] + chunk.append(line) + return out + + +def selectors_of(start: str, read) -> set[str]: + """Every dependency the manifest `start` declares, with those of the + manifests it reaches by `path` inside the same archive; `read(path)` is a + manifest's text.""" + selectors: set[str] = set() + seen, todo = {start}, [start] + while todo: + inside = todo.pop() + manifest = loads(read(inside)) + for table in dependency_tables(manifest): + for selector, spec in declared(table): + selectors.add(selector) + if isinstance(spec, dict) and isinstance(spec.get("path"), str): + nxt = posixpath.normpath(posixpath.join(posixpath.dirname(inside), + spec["path"], "mcpp.toml")) + if not nxt.startswith("..") and nxt not in seen: # inside the archive + seen.add(nxt) + todo.append(nxt) + return selectors + + +class NoManifest(Exception): + """No manifest to read: the archive holds none where an optional one would + be, or the url is a single file rather than an archive.""" + + +def archive_selectors(location: str, get=fetch) -> set[str]: + """The dependencies stated by the manifest at `location` and the manifests + it reaches: from the repository's tree at the archive's ref, or, when the + tree does not hold it (a release asset), from the archive. A manifest + marked optional (`#?mcpp.toml`) that is not there raises NoManifest.""" + url, _, inside = location.partition("#") + optional = inside.startswith("?") + if not re.search(r"\.(tar\.gz|tgz|tar\.xz|tar\.bz2|zip)$", url): + raise NoManifest(location) # a single file: install() writes the manifest + raw = raw_location(f"{url}#{inside.lstrip('?')}") + if raw is None: + raise ValueError(f"not a GitHub archive: {url}") + prefix, start = raw + try: + return selectors_of(start, lambda path: get(f"{prefix}/{path}").decode()) + except urllib.error.HTTPError as e: + if e.code != 404: + raise + files = archive_files(get(url)) + if start not in files: + raise NoManifest(location) # optional, or written by install() (marzer.tomlplusplus) + return selectors_of(start, lambda path: files[path].decode()) + + +# ── The selection ─────────────────────────────────────────────────────────── + +def members() -> list[Path]: + workspace = tomllib.loads((ROOT / "mcpp.toml").read_text())["workspace"]["members"] + return [ROOT / m for m in workspace if (ROOT / m / "mcpp.toml").is_file()] + + +def member_selectors(member: Path) -> set[str]: + manifest = tomllib.loads((member / "mcpp.toml").read_text()) + return {selector for table in dependency_tables(manifest) for selector, _ in declared(table)} + + +class Graph: + """Edges of this index's packages; an archive's manifest is read once.""" + + def __init__(self, index: Index, get=fetch, warn=lambda message: None, + cache: dict[str, list[str]] | None = None): + self.index, self.get, self.warn = index, get, warn + self.cache = cache if cache is not None else {} + self.read: dict[str, set[str] | None] = {k: set(v) for k, v in self.cache.items()} + self.edges: dict[Identity, set[Identity]] = {} + + def _read(self, location: str) -> set[str] | None: + try: + return archive_selectors(location, self.get) + except NoManifest: + return set() + except Exception as e: # noqa: BLE001 -- every failure is the same fact: an unseen edge + self.warn(f"::warning::cannot read the manifest {location}: {e}") + return None + + def prefetch(self, idents: set[Identity]) -> None: + """Read, in parallel, the archive manifests of `idents` not read yet.""" + todo = sorted({loc for i in idents for loc in self.index.manifests.get(i, ())} - set(self.read)) + with concurrent.futures.ThreadPoolExecutor(16) as pool: + for loc, selectors in zip(todo, pool.map(self._read, todo)): + self.read[loc] = selectors + if selectors is not None: + self.cache[loc] = sorted(selectors) + + def of(self, ident: Identity) -> set[Identity]: + if ident not in self.edges: + self.prefetch({ident}) + spellings = set(self.index.deps.get(ident, ())) + for loc in self.index.manifests.get(ident, ()): + spellings |= self.read[loc] or set() + self.edges[ident] = {t for s in spellings for t in resolve(s, self.index.identities)} - {ident} + return self.edges[ident] + + def closure(self, start: set[Identity]) -> set[Identity]: + seen, todo = set(start), list(start) + while todo: + for nxt in self.of(todo.pop()): + if nxt not in seen: + seen.add(nxt) + todo.append(nxt) + return seen + + +def select(changed: list[str], base: str | None = None, get=fetch, + warn=lambda message: print(message, file=sys.stderr), + cache: dict[str, list[str]] | None = None) -> tuple[list[str], list[str]]: + files = sorted(str(p.relative_to(ROOT)) for p in ROOT.glob("pkgs/*/*.lua")) + index = Index(dump(files)) + changed_ids: dict[str, Identity] = {p: index.identity_of[p] for p in changed if p in index.identity_of} + missing = [p for p in changed if p not in index.identity_of] + if missing and base: + changed_ids.update(deleted_identities(missing, base)) + for path in changed: + if path not in changed_ids: + warn(f"::warning::{path}: no identity read; no member selected for it") + + graph = Graph(index, get, warn, cache) + direct = {m: {t for s in member_selectors(m) for t in resolve(s, index.identities)} + for m in members()} + # Read the archive manifests the members can reach a level at a time, each + # level in parallel, before walking. + reachable, frontier = set(), set().union(*direct.values()) + while frontier: + graph.prefetch(frontier) + reachable |= frontier + frontier = {n for i in frontier for n in graph.of(i)} - reachable + + selected: list[str] = [] + reached: set[str] = set() + for member, ids in direct.items(): + hits = graph.closure(ids) & set(changed_ids.values()) + if hits: + selected.append(member.name) + reached |= {p for p, i in changed_ids.items() if i in hits} + return selected, [p for p in changed if p not in reached] + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base") + parser.add_argument("--cache", type=Path) + parser.add_argument("descriptors", nargs="*") + args = parser.parse_args(argv) + cache = json.loads(args.cache.read_text()) if args.cache and args.cache.is_file() else {} + selected, unreached = select(args.descriptors, args.base, cache=cache) + if args.cache: + args.cache.write_text(json.dumps(cache, indent=0, sort_keys=True)) + for path in unreached: + print(f"note: no workspace member exercises {path}", file=sys.stderr) + print(" ".join(selected)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_select_members.py b/tests/test_select_members.py new file mode 100644 index 00000000..83e961bc --- /dev/null +++ b/tests/test_select_members.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""tests/select_members.py: a member is selected by the identities its declared +graph holds, never by a substring of a manifest.""" +import io +import tarfile +import tomllib +import unittest +import urllib.error + +import select_members as s + + +def not_found(url): + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) + + +def tarball(files: dict[str, str]) -> bytes: + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as t: + for name, text in files.items(): + data = text.encode() + info = tarfile.TarInfo(name) + info.size = len(data) + t.addfile(info, io.BytesIO(data)) + return out.getvalue() + + +class Declared(unittest.TestCase): + def selectors(self, text): + manifest = tomllib.loads(text) + return {sel for table in s.dependency_tables(manifest) for sel, _ in s.declared(table)} + + def test_nesting_is_read_as_mcpp_reads_it(self): + text = ''' +[package] +name = "m" # not a dependency, though a string +[dependencies] +cmdline = "0.0.2" +"mcpplibs.mwrap.ffmpeg" = "8.1.2" +openkal.workspace = true +[dependencies.compat] +fmt = "12.2.0" +zlib = { version = "1.3.1", features = ["x"] } +[dependencies.mcpplibs.mwrap] +imgui = "1.92.8" +[target.'cfg(os = "linux")'.dependencies.freedesktop] +wayland = "1.26.0" +[target.'cfg(os = "openkal")'.build-dependencies] +openkal-tools = { version = "0.1.0", tools = ["okld"] } +''' + self.assertEqual(self.selectors(text), { + "cmdline", "mcpplibs.mwrap.ffmpeg", "openkal", "compat.fmt", "compat.zlib", + "mcpplibs.mwrap.imgui", "freedesktop.wayland", "openkal-tools"}) + + def test_a_spelling_names_identities_of_this_index_only(self): + ids = {("mcpplibs", "os"), ("compat", "openssl"), ("mcpplibs.mwrap", "ffmpeg"), ("compat", "os")} + self.assertEqual(s.resolve("os", ids), {("mcpplibs", "os")}) + self.assertEqual(s.resolve("compat.openssl", ids), {("compat", "openssl")}) + self.assertEqual(s.resolve("compat:openssl@3.5.1", ids), {("compat", "openssl")}) + self.assertEqual(s.resolve("mcpplibs.mwrap.ffmpeg", ids), {("mcpplibs.mwrap", "ffmpeg")}) + self.assertEqual(s.resolve("xim:gcc@16.1.0", ids), set()) + self.assertEqual(s.resolve("macos", ids), set()) + + def test_a_table_opened_twice_adds_to_itself(self): + text = '[build]\nflags = ["a"]\n[dependencies]\nos = "0.1.0"\n[build]\nsources = ["b"]\n' + self.assertEqual(s.loads(text), {"build": {"flags": ["a"], "sources": ["b"]}, + "dependencies": {"os": "0.1.0"}}) + + +class Archives(unittest.TestCase): + TOOLS = 'openkal-ld = { path = "../ld" }\nos = "0.1.0"\n' + LD = 'openkal = "0.17.0"\n' + + def test_the_tree_at_the_tag_and_the_manifests_it_reaches_by_path(self): + tree = { + "https://raw.githubusercontent.com/o/r/0.1.0/tools/mcpp.toml": "[dependencies]\n" + self.TOOLS, + "https://raw.githubusercontent.com/o/r/0.1.0/ld/mcpp.toml": "[dependencies]\n" + self.LD, + } + get = lambda url: tree[url].encode() if url in tree else not_found(url) + got = s.archive_selectors("https://github.com/o/r/archive/refs/tags/0.1.0.tar.gz#tools/mcpp.toml", get) + self.assertEqual(got, {"openkal-ld", "os", "openkal"}) + + def test_a_release_asset_is_read_from_the_archive(self): + asset = "https://github.com/o/r/releases/download/v1/r-1.tar.gz" + data = tarball({"r-1/mcpp/mcpp.toml": "[dependencies.compat]\nzlib = \"1.3.1\"\n"}) + get = lambda url: data if url == asset else not_found(url) + self.assertEqual(s.archive_selectors(asset + "#mcpp/mcpp.toml", get), {"compat.zlib"}) + + def test_an_optional_manifest_that_is_absent_is_no_edge(self): + asset = "https://github.com/o/r/releases/download/v1/r-1.tar.gz" + data = tarball({"r-1/README": "x"}) + get = lambda url: data if url == asset else not_found(url) + with self.assertRaises(s.NoManifest): + s.archive_selectors(asset + "#?mcpp.toml", get) + with self.assertRaises(s.NoManifest): + s.archive_selectors("https://example.org/COPYING#mcpp.toml", get) + + +class ThisIndex(unittest.TestCase): + """The selection on this repository's descriptors and members, with every + archive manifest unreadable: what the descriptors themselves state.""" + + def select(self, *paths): + return set(s.select(list(paths), get=not_found, warn=lambda m: None, cache={})[0]) + + def test_a_short_name_inside_other_words_selects_nothing(self): + # `openkal` occurs in no member that does not declare a package of the family. + for member in self.select("pkgs/o/openkal.lua"): + text = (s.ROOT / "tests" / "examples" / member / "mcpp.toml").read_text() + self.assertRegex(text, r"(?m)^\s*openkal(-[a-z-]+)?\s*[=.]") + + def test_a_dependency_stated_by_descriptors_reaches_its_consumers(self): + selected = self.select("pkgs/c/compat.boost-config.lua") + self.assertIn("boost-config", selected) + self.assertIn("boost-asio", selected) # boost-asio -> boost-config, by descriptor + self.assertNotIn("fmt", selected) + + +if __name__ == "__main__": + unittest.main()