commit f81344e11f773bfd85e2e99e21ffdcc98296ca41 Author: Morgan Date: Sat Feb 14 02:58:39 2026 +0900 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..11fed0a --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# mock-dep-resolver + +Resolve both forward and reverse dependancy for mock chain builds + + + +### Usage +``` +usage: mock-dep-resolver.py [-h] [-r RELEASE] [-s SOURCE] [--download [PATH]] [--forward] [--reverse] [-v] package + +Resolve deps for cross-release SRPM building via mock + +positional arguments: + package Package name or path to .src.rpm + +options: + -h, --help show this help message and exit + -r, --release RELEASE + Target Fedora release (default: 43) + -s, --source SOURCE Source release (default: rawhide) + --download [PATH] Download SRPMs (default: ./SRPMS) + --forward Only resolve build deps + --reverse Only resolve reverse deps + -v, --verbose +``` + + +### Example +*For building rawhide gnome-shell for f43* + +``` +$ python3 mock-dep-resolver.py gnome-shell + + Package: gnome-shell + Target: Fedora 43 + Source: rawhide + Mode: both + Download: no + +[INFO] Loading target repo... +[INFO] Loading source repo... +[INFO] Resolving build dependencies... + +[INFO] Build deps: gnome-shell +[WARN] UNMET: mutter-devel >= 50~alpha +[INFO] Build deps: mutter + +[INFO] Reverse (#1): +[WARN] BROKEN: gnome-shell-extension-background-logo +[WARN] requires: gnome-shell(api) = 49 +[WARN] old: gnome-shell(api) = 49 +[WARN] new: gnome-shell(api) = 50 + +[INFO] Forward (#1): +[INFO] Need rebuild: gnome-shell-extension-background-logo (source: gnome-shell-extension-background-logo) +[INFO] Build deps: gnome-shell-extension-background-logo + +[INFO] Reverse (#2): +[OK] No more reverse dependency + +====== DONE ====== + +[OK] Build order (3 packages): + + 1. mutter + 2. gnome-shell + 3. gnome-shell-extension-background-logo + +[OK] Download SRPMs: + + dnf download --source --releasever=rawhide mutter + dnf download --source --releasever=rawhide gnome-shell + dnf download --source --releasever=rawhide gnome-shell-extension-background-logo + +[OK] Command for Mock: + +mock --chain -r fedora-43-x86_64 --localrepo ~/repo \ + mutter-*.src.rpm \ + gnome-shell-*.src.rpm \ + gnome-shell-extension-background-logo-*.src.rpm + +``` diff --git a/mock-dep-resolver.py b/mock-dep-resolver.py new file mode 100644 index 0000000..527b37d --- /dev/null +++ b/mock-dep-resolver.py @@ -0,0 +1,595 @@ +#!/usr/bin/env python3 +""" +mock-dep-resolver.py - Resolve deps for cross-release SRPM building via mock + +positional arguments: + package Package name or path to .src.rpm + +options: + -h, --help show this help message and exit + -r, --release RELEASE + Target Fedora release (default: 43) + -s, --source SOURCE Source release (default: rawhide) + --download [PATH] Download SRPMs (default: ./SRPMS) + --forward Only resolve build deps + --reverse Only resolve reverse deps + -v, --verbose +""" + +import argparse +import os +import platform +import re +import subprocess +import sys +import tempfile +from collections import OrderedDict +from pathlib import Path + +try: + import rpm +except ImportError: + sys.exit("Error: python3-rpm required (sudo dnf install python3-rpm)") +try: + import dnf +except ImportError: + sys.exit("Error: python3-dnf required (sudo dnf install python3-dnf)") + + +class C: + R = '\033[0;31m' if sys.stderr.isatty() else '' + G = '\033[0;32m' if sys.stderr.isatty() else '' + Y = '\033[1;33m' if sys.stderr.isatty() else '' + B = '\033[0;34m' if sys.stderr.isatty() else '' + CN = '\033[0;36m' if sys.stderr.isatty() else '' + DIM = '\033[2m' if sys.stderr.isatty() else '' + N = '\033[0m' if sys.stderr.isatty() else '' + +def log(m): print(f"{C.B}[INFO]{C.N} {m}", file=sys.stderr) +def warn(m): print(f"{C.Y}[WARN]{C.N} {m}", file=sys.stderr) +def err(m): print(f"{C.R}[ERROR]{C.N} {m}", file=sys.stderr) +def ok(m): print(f"{C.G}[OK]{C.N} {m}", file=sys.stderr) +def debug(m, v): + if v: print(f" {C.B}[ยท]{C.N} {m}", file=sys.stderr) # noqa: E701 + + +def sourcerpm_to_name(s): + return re.sub(r'-[^-]+-[^-]+\.src\.rpm$', '', s) if s else None + + +### Repo querier ### + +class RepoQuerier: + def __init__(self, releasever, enable_source=False, verbose=False): + self.releasever = str(releasever) + self.enable_source = enable_source + self.verbose = verbose + self._base = None + + def _add_repos(self, base): + arch = platform.machine() + if self.releasever == 'rawhide': + self._add_repo(base, 'rawhide', + f'https://mirrors.fedoraproject.org/metalink?repo=rawhide&arch={arch}') + if self.enable_source: + self._add_repo(base, 'rawhide-source', + f'https://mirrors.fedoraproject.org/metalink?repo=rawhide-source&arch={arch}') + else: + rv = self.releasever + self._add_repo(base, 'fedora', + f'https://mirrors.fedoraproject.org/metalink?repo=fedora-{rv}&arch={arch}') + self._add_repo(base, 'updates', + f'https://mirrors.fedoraproject.org/metalink?repo=updates-released-f{rv}&arch={arch}') + if self.enable_source: + self._add_repo(base, 'fedora-source', + f'https://mirrors.fedoraproject.org/metalink?repo=fedora-source-{rv}&arch={arch}') + self._add_repo(base, 'updates-source', + f'https://mirrors.fedoraproject.org/metalink?repo=updates-released-source-f{rv}&arch={arch}') + + @staticmethod + def _add_repo(base, repo_id, metalink): + repo = dnf.repo.Repo(repo_id, base.conf) + repo.metalink = metalink + repo.gpgcheck = False + repo.skip_if_unavailable = True + base.repos.add(repo) + repo.enable() + + def _get_base(self): + if self._base is None: + debug(f"Loading repos for releasever={self.releasever} " + f"(source={'yes' if self.enable_source else 'no'})", self.verbose) + b = dnf.Base() + b.conf.releasever = self.releasever + b.conf.cachedir = os.path.join( + tempfile.gettempdir(), f'mock-dep-resolver-{self.releasever}') + b.conf.substitutions['releasever'] = self.releasever + self._add_repos(b) + enabled = [r.id for r in b.repos.iter_enabled()] + debug(f"Enabled repos: {', '.join(enabled)}", self.verbose) + b.fill_sack(load_system_repo=False) + self._base = b + return self._base + + def get_build_requires(self, pkg_name): + base = self._get_base() + q = base.sack.query().available().filter(name=pkg_name, arch='src') + pkgs = list(q) + if not pkgs: + return None + reqs = [] + for r in pkgs[-1].requires: + s = str(r) + if not s.startswith('rpmlib('): + reqs.append(s) + return reqs + + def is_dep_satisfied(self, dep_str): + base = self._get_base() + return len(base.sack.query().available().filter(provides=dep_str)) > 0 + + def dep_to_source(self, dep_str): + base = self._get_base() + for pkg in base.sack.query().available().filter(provides=dep_str): + if pkg.sourcerpm: + return sourcerpm_to_name(pkg.sourcerpm) + return None + + def binary_to_source(self, pkg_name): + base = self._get_base() + for pkg in base.sack.query().available().filter(name=pkg_name): + if pkg.sourcerpm: + return sourcerpm_to_name(pkg.sourcerpm) + return None + + def get_provides(self, pkg_name): + base = self._get_base() + provs = [] + for pkg in base.sack.query().available().filter(name=pkg_name): + for p in pkg.provides: + provs.append(str(p)) + return provs + + def source_to_binaries(self, src_name): + base = self._get_base() + bins = set() + for pkg in base.sack.query().available(): + if pkg.sourcerpm and sourcerpm_to_name(pkg.sourcerpm) == src_name: + bins.add(pkg.name) + return bins + + def close(self): + if self._base: + self._base.close() + self._base = None + + +### Local RPM helpers ### + +def get_srpm_build_requires(srpm_path): + ts = rpm.TransactionSet() + ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES | rpm._RPMVSF_NODIGESTS) + with open(srpm_path, 'rb') as f: + hdr = ts.hdrFromFdno(f) + name = hdr[rpm.RPMTAG_NAME] + if isinstance(name, bytes): + name = name.decode() + reqs = [] + rn = hdr[rpm.RPMTAG_REQUIRENAME] + rf = hdr[rpm.RPMTAG_REQUIREFLAGS] + rv = hdr[rpm.RPMTAG_REQUIREVERSION] + if rn: + for i, n in enumerate(rn): + if isinstance(n, bytes): + n = n.decode() + if n.startswith('rpmlib('): + continue + dep = n + if rf and rv and rv[i]: + v = rv[i] + if isinstance(v, bytes): + v = v.decode() + if v: + fl = rf[i] + op = '' + if fl & rpm.RPMSENSE_LESS: + op += '<' + if fl & rpm.RPMSENSE_GREATER: + op += '>' + if fl & rpm.RPMSENSE_EQUAL: + op += '=' + if op: dep = f"{n} {op} {v}" + reqs.append(dep) + return name, reqs + + +def get_installed_provides(pkg_name): + ts = rpm.TransactionSet() + provs = [] + for hdr in ts.dbMatch('name', pkg_name): + pn = hdr[rpm.RPMTAG_PROVIDENAME] + pf = hdr[rpm.RPMTAG_PROVIDEFLAGS] + pv = hdr[rpm.RPMTAG_PROVIDEVERSION] + if pn: + for i, n in enumerate(pn): + if isinstance(n, bytes): + n = n.decode() + s = n + if pf and pv and pv[i]: + v = pv[i] + if isinstance(v, bytes): + v = v.decode() + if v: + fl = pf[i] + op = '' + if fl & rpm.RPMSENSE_LESS: + op += '<' + if fl & rpm.RPMSENSE_GREATER: + op += '>' + if fl & rpm.RPMSENSE_EQUAL: + op += '=' + if op: s = f"{n} {op} {v}" + provs.append(s) + return provs + + +def get_installed_reverse_deps(provide_name): + r = subprocess.run(['rpm', '-q', '--whatrequires', provide_name], + capture_output=True, text=True) + pkgs = set() + for line in r.stdout.strip().splitlines(): + if line.startswith('no package'): + continue + n = subprocess.run(['rpm', '-q', '--qf', '%{NAME}', line], + capture_output=True, text=True).stdout.strip() + if n: + pkgs.add(n) + return pkgs + + +def get_installed_requires(pkg_name): + ts = rpm.TransactionSet() + reqs = [] + for hdr in ts.dbMatch('name', pkg_name): + rn = hdr[rpm.RPMTAG_REQUIRENAME] + rf = hdr[rpm.RPMTAG_REQUIREFLAGS] + rv = hdr[rpm.RPMTAG_REQUIREVERSION] + if rn: + for i, n in enumerate(rn): + if isinstance(n, bytes): + n = n.decode() + if n.startswith('rpmlib('): + continue + v, op = '', '' + if rf and rv and rv[i]: + v = rv[i] + if isinstance(v, bytes): + v = v.decode() + if v: + fl = rf[i] + if fl & rpm.RPMSENSE_LESS: + op += '<' + if fl & rpm.RPMSENSE_GREATER: + op += '>' + if fl & rpm.RPMSENSE_EQUAL: + op += '=' + reqs.append((n, op, v)) + return reqs + + +def is_pkg_installed(pkg_name): + ts = rpm.TransactionSet() + return ts.dbMatch('name', pkg_name).count() > 0 + + +def download_srpm(pkg_name, source_release, workdir, verbose=False): + workdir = Path(workdir) + workdir.mkdir(parents=True, exist_ok=True) + for f in workdir.glob(f"{pkg_name}-*.src.rpm"): + debug(f"Already have: {f.name}", verbose) + return str(f) + log(f"Downloading SRPM: {pkg_name}") + r = subprocess.run( + ['dnf', 'download', '--source', f'--releasever={source_release}', + '--destdir', str(workdir), pkg_name], + capture_output=True, text=True) + if r.returncode != 0: + err(f"Failed to download {pkg_name}: {r.stderr.strip()}") + return None + for f in sorted(workdir.glob(f"{pkg_name}-*.src.rpm"), + key=os.path.getmtime, reverse=True): + return str(f) + return None + + +### Forward resolution ### + +def resolve_forward(pkg_name, srpm_path, target_repo, source_repo, + source_release, download_dir, verbose, + visited, build_order, all_to_build, dep_children): + if pkg_name in visited: + return + visited.add(pkg_name) + dep_children.setdefault(pkg_name, []) + + if srpm_path and os.path.isfile(srpm_path): + _, breqs = get_srpm_build_requires(srpm_path) + else: + breqs = source_repo.get_build_requires(pkg_name) + if breqs is None: + err(f"Cannot find source package '{pkg_name}' in {source_release}") + return + + log(f"Build deps: {pkg_name}") + + for dep in breqs: + if target_repo.is_dep_satisfied(dep): + debug(f"ok: {dep}", verbose) + else: + warn(f" UNMET: {dep}") + src = source_repo.dep_to_source(dep) + if not src: + err(f" Cannot find source for: {dep}") + continue + debug(f" -> source: {src}", verbose) + if src not in [c for c, _ in dep_children[pkg_name]]: + dep_children[pkg_name].append((src, "build")) + if src in visited: + continue + dep_srpm = None + if download_dir: + dep_srpm = download_srpm(src, source_release, download_dir, verbose) + resolve_forward(src, dep_srpm, target_repo, source_repo, + source_release, download_dir, verbose, + visited, build_order, all_to_build, dep_children) + all_to_build[src] = dep_srpm + if not any(n == src for n, _ in build_order): + build_order.append((src, dep_srpm)) + + all_to_build[pkg_name] = srpm_path + if not any(n == pkg_name for n, _ in build_order): + build_order.append((pkg_name, srpm_path)) + + +### Reverse resolution ### + +def resolve_reverse(all_to_build, source_repo, visited, verbose): + newly_broken = [] + checked = set() + + for src_name in list(all_to_build.keys()): + binaries = source_repo.source_to_binaries(src_name) + binaries.add(src_name) + + for bin_pkg in binaries: + if bin_pkg in checked: + continue + checked.add(bin_pkg) + + if not is_pkg_installed(bin_pkg): + debug(f"revdep: {bin_pkg} not installed", verbose) + continue + + debug(f"revdep: checking {bin_pkg}", verbose) + + old_provs = set(get_installed_provides(bin_pkg)) + new_provs = set(source_repo.get_provides(bin_pkg)) + if not old_provs or not new_provs: + continue + + removed = old_provs - new_provs + debug(f" {len(old_provs)} old, {len(new_provs)} new, {len(removed)} removed", verbose) + + for old_prov in removed: + prov_name = re.split(r'\s*[<>=]', old_prov)[0].strip() + rdeps = get_installed_reverse_deps(prov_name) + for rdep_name in rdeps: + if rdep_name in visited: + continue + rdep_src = source_repo.binary_to_source(rdep_name) + if rdep_src and rdep_src in visited: + continue + for rn, op, ver in get_installed_requires(rdep_name): + if rn == prov_name and op == '=' and ver: + req_str = f"{rn} = {ver}" + if req_str not in new_provs: + new_match = [p for p in new_provs if p.startswith(f"{prov_name} ")] + warn(f" BROKEN: {rdep_name}") + warn(f" requires: {req_str}") + warn(f" old: {old_prov}") + warn(f" new: {new_match[0] if new_match else '(removed)'}") + newly_broken.append((rdep_name, bin_pkg)) + + old_so = {p for p in old_provs if '.so' in p} + new_so = {p for p in new_provs if '.so' in p} + for libname in (old_so - new_so): + debug(f" removed lib: {libname}", verbose) + so_base = libname.split('(')[0] + rdeps = get_installed_reverse_deps(so_base) + for rdep_name in rdeps: + if rdep_name in visited: + continue + rdep_src = source_repo.binary_to_source(rdep_name) + if rdep_src and rdep_src in visited: + continue + if any(rdep_name == n for n, _ in newly_broken): + continue + warn(f" BROKEN (lib): {rdep_name} requires {libname}") + newly_broken.append((rdep_name, bin_pkg)) + + seen = set() + unique = [] + for name, parent in newly_broken: + if name not in seen: + seen.add(name) + unique.append((name, parent)) + return unique + + +### Output ### + +def print_results(build_order, mock_config, dep_children, root_name, source_release): + print(file=sys.stderr) + print("====== DONE ======", file=sys.stderr) + + seen = set() + final = [] + for name, path in build_order: + if name not in seen: + seen.add(name) + final.append((name, path)) + + if not final: + ok("Nothing to build.") + return + + if len(final) == 1 and final[0][1]: + ok("No dependency found:") + print(f" mock -r {mock_config} {final[0][1]}", file=sys.stderr) + print(final[0][1]) + return + + print(file=sys.stderr) + ok(f"Build order ({len(final)} packages):") + print(file=sys.stderr) + for i, (name, path) in enumerate(final, 1): + label = os.path.basename(path) if path else name + print(f" {i}. {C.CN}{label}{C.N}", file=sys.stderr) + + print(file=sys.stderr) + ok("Download SRPMs:") + print(file=sys.stderr) + for name, _ in final: + print(f" dnf download --source --releasever={source_release} {name}", file=sys.stderr) + + print(file=sys.stderr) + ok("Command for Mock:") + print(file=sys.stderr) + parts = [f"mock --chain -r {mock_config} --localrepo ~/repo"] + for name, path in final: + entry = path if path else f"{name}-*.src.rpm" + parts.append(f" {entry}") + print(" \\\n".join(parts), file=sys.stderr) + print(file=sys.stderr) + + +### Main ### + +def main(): + p = argparse.ArgumentParser(description='Resolve deps for cross-release SRPM building via mock') + p.add_argument('package', help='Package name or path to .src.rpm') + p.add_argument('-r', '--release', default='43', help='Target Fedora release (default: 43)') + p.add_argument('-s', '--source', default='rawhide', help='Source release (default: rawhide)') + p.add_argument('--download', nargs='?', const='./SRPMS', default=None, + metavar='PATH', help='Download SRPMs (default: ./SRPMS)') + p.add_argument('--forward', action='store_true', help='Only resolve build deps') + p.add_argument('--reverse', action='store_true', help='Only resolve reverse deps') + p.add_argument('-v', '--verbose', action='store_true') + args = p.parse_args() + + do_fwd = True + do_rev = True + if args.forward or args.reverse: + do_fwd = args.forward + do_rev = args.reverse + + srpm_path = None + if os.path.isfile(args.package) and args.package.endswith('.src.rpm'): + srpm_path = os.path.realpath(args.package) + pkg_name, _ = get_srpm_build_requires(srpm_path) + else: + pkg_name = args.package + + mock_config = f"fedora-{args.release}-x86_64" + + print(file=sys.stderr) + sfx = f" ({os.path.basename(srpm_path)})" if srpm_path else "" + print(f" Package: {pkg_name}{sfx}", file=sys.stderr) + print(f" Target: Fedora {args.release}", file=sys.stderr) + print(f" Source: {args.source}", file=sys.stderr) + mode = 'forward' if do_fwd and not do_rev else 'reverse' if do_rev and not do_fwd else 'both' + print(f" Mode: {mode}", file=sys.stderr) + print(f" Download: {args.download if args.download else 'no'}", file=sys.stderr) + print(file=sys.stderr) + + log("Loading target repo...") + target_repo = RepoQuerier(args.release, enable_source=False, verbose=args.verbose) + log("Loading source repo...") + source_repo = RepoQuerier(args.source, enable_source=True, verbose=args.verbose) + + visited = set() + build_order = [] + all_to_build = OrderedDict() + dep_children = {} # name -> [(child_name, "build"|"revdep")] + + if do_fwd: + log("Resolving build dependencies...") + print(file=sys.stderr) + resolve_forward(pkg_name, srpm_path, target_repo, source_repo, + args.source, args.download, args.verbose, + visited, build_order, all_to_build, dep_children) + + if not do_rev: + print_results(build_order, mock_config, dep_children, pkg_name, args.source) + target_repo.close() + source_repo.close() + return + + if not do_fwd: + all_to_build[pkg_name] = srpm_path + visited.add(pkg_name) + dep_children.setdefault(pkg_name, []) + + for iteration in range(1, 21): + print(file=sys.stderr) + log(f"Reverse (#{iteration}):") + + broken = resolve_reverse(all_to_build, source_repo, visited, args.verbose) + + if not broken: + ok("No more reverse dependency") + break + + print(file=sys.stderr) + log(f"Forward (#{iteration}):") + added = False + for bbin, parent_bin in broken: + bsrc = source_repo.binary_to_source(bbin) + if not bsrc: + err(f"Cannot find source for: {bbin}") + continue + if bsrc in visited: + continue + log(f"Need rebuild: {bbin} (source: {bsrc})") + added = True + + parent_src = source_repo.binary_to_source(parent_bin) or parent_bin + dep_children.setdefault(parent_src, []) + if bsrc not in [c for c, _ in dep_children[parent_src]]: + dep_children[parent_src].append((bsrc, "revdep")) + + dep_srpm = None + if args.download: + dep_srpm = download_srpm(bsrc, args.source, args.download, args.verbose) + if do_fwd: + resolve_forward(bsrc, dep_srpm, target_repo, source_repo, + args.source, args.download, args.verbose, + visited, build_order, all_to_build, dep_children) + else: + visited.add(bsrc) + all_to_build[bsrc] = dep_srpm + build_order.append((bsrc, dep_srpm)) + + if not added: + ok("Done") + break + else: + err("Too many iterations (20).") + + print_results(build_order, mock_config, dep_children, pkg_name, args.source) + target_repo.close() + source_repo.close() + + +if __name__ == '__main__': + main()