#!/usr/bin/env python3 # Copyright (C) 2023 Umorpha Systems # SPDX-License-Identifier: AGPL-3.0-or-later from typing import Callable, Literal, NamedTuple, Optional, TypeAlias, cast import pyalpm from pycman import config __version__ = "20231024" ################################################################################ # pyalpm doesn't expose the alpm_find_dbs_satisfier() function, so we # replicate it here. Refer to deps.c in pacman.git. alpm_depmod_t: TypeAlias = Literal[None, "<", "<=", "=", ">=", ">"] class alpm_depend_t(NamedTuple): name: str version: Optional[str] desc: Optional[str] mod: alpm_depmod_t def vercmp(self, ver: str) -> bool: if self.mod is None: return True assert self.version if self.mod == "<": return pyalpm.vercmp(ver, self.version) < 0 elif self.mod == "<=": return pyalpm.vercmp(ver, self.version) <= 0 elif self.mod == "=": return pyalpm.vercmp(ver, self.version) == 0 elif self.mod == ">=": return pyalpm.vercmp(ver, self.version) >= 0 elif self.mod == ">": return pyalpm.vercmp(ver, self.version) > 0 def _depcmp_literal(self, pkg: pyalpm.Package) -> bool: return pkg.name == self.name and self.vercmp(pkg.version) def _depcmp_provides(self, pkg: pyalpm.Package) -> bool: for pvdstring in pkg.provides: pvd = alpm_dep_from_string(pvdstring) if self.mod is None: if pvd.name == self.name: return True else: if ( pvd.version is not None and pvd.name == self.name and self.vercmp(pvd.version) ): return True return False def depcmp(self, pkg: pyalpm.Package) -> bool: return self._depcmp_literal(pkg) or self._depcmp_provides(pkg) def alpm_dep_from_string(depstring: str) -> alpm_depend_t: desc: Optional[str] = None if (idx := depstring.find(": ")) >= 0: desc = depstring[idx + 2 :] depstring = depstring[:idx] mod: alpm_depmod_t = None ver: Optional[str] = None if (idx := depstring.find("<")) >= 0: if depstring[idx + 1 :].startswith("="): mod = cast(alpm_depmod_t, depstring[idx : idx + 2]) ver = depstring[idx + 2 :] else: mod = cast(alpm_depmod_t, depstring[idx : idx + 1]) ver = depstring[idx + 1 :] depstring = depstring[:idx] elif (idx := depstring.find(">")) >= 0: if depstring[idx + 1 :].startswith("="): mod = cast(alpm_depmod_t, depstring[idx : idx + 2]) ver = depstring[idx + 2 :] else: mod = cast(alpm_depmod_t, depstring[idx : idx + 1]) ver = depstring[idx + 1 :] depstring = depstring[:idx] elif (idx := depstring.find("=")) >= 0: mod = cast(alpm_depmod_t, depstring[idx : idx + 1]) ver = depstring[idx + 1 :] depstring = depstring[:idx] return alpm_depend_t( name=depstring, version=ver, desc=desc, mod=mod, ) def alpm_find_dbs_satisfier( handle: pyalpm.Handle, dbs: list[pyalpm.DB], depstring: str, questioncb: Callable[[list[pyalpm.Package]], Optional[pyalpm.Package]], ) -> Optional[pyalpm.Package]: # BUG(lukeshu): alpm_find_dbs_satisfier doesn't bother to # replicate the 'nodepversion' transaction option nor the # 'ignorepkgs' and 'ignoregrps' config lists. dep = alpm_dep_from_string(depstring) # Literal for db in dbs: pkg = db.get_pkg(dep.name) if pkg and dep._depcmp_literal(pkg): return pkg # Provides providers: list[pyalpm.Package] = [] for db in dbs: for pkg in db.pkgcache: if pkg.name != dep.name and dep._depcmp_provides(pkg): providers.append(pkg) if len(providers) == 0: return None if len(providers) == 1: return providers[0] return questioncb(providers) ################################################################################ def get_all_questions(handle: pyalpm.Handle) -> dict[str, list[pyalpm.Package]]: questions: dict[str, list[pyalpm.Package]] = dict() by_name: dict[str, list[pyalpm.Package]] = dict() def insert(name: str, pkg: pyalpm.Package) -> None: nonlocal by_name if name not in by_name: by_name[name] = [] if pkg not in by_name[name]: by_name[name].append(pkg) for db in handle.get_syncdbs(): for pkg in db.pkgcache: insert(pkg.name, pkg) for pvdstring in pkg.provides: pvd = alpm_dep_from_string(pvdstring) insert(pvd.name, pkg) for name, pkgs in by_name.items(): if len(pkgs) < 2 or name.endswith(".so"): continue if not any(pkg.name == name for pkg in pkgs): questions[name] = pkgs return questions def get_questions( handle: pyalpm.Handle, targets: list[str] ) -> dict[str, list[pyalpm.Package]]: questions: dict[str, list[pyalpm.Package]] = dict() done: set[str] = set() for target in targets: if target in done: continue providers: Optional[list[pyalpm.Package]] = None target_dep = alpm_dep_from_string(target) def questioncb(opts: list[pyalpm.Package]) -> Optional[pyalpm.Package]: nonlocal providers if target_dep.name.endswith(".so"): return opts[0] else: providers = opts return None satisfier = alpm_find_dbs_satisfier( handle, handle.get_syncdbs(), target, questioncb ) if providers: questions[target] = providers if satisfier: targets += satisfier.depends done.add(target) return questions def main() -> None: parser = config.make_parser( prog="pacman-choices", description="""Show package choices that pacman would consider when when asked to install `targets`. This does NOT take in to account when pacman would automatically decide the answer based on other items in the package list or what's already installed; it presents these questions even though pacman would hide them.""", ) parser.add_argument( "-V", "--version", action="version", version=f"%(prog)s (osi-tools) {__version__}", ) parser.add_argument( "--all", action="store_true", help="show all possible questions, not just the ones in the target list", ) parser.add_argument( "--oneline", action="store_true", help="show each question on one line, rather than in a multi-line format", ) parser.add_argument("targets", nargs="*") args = parser.parse_args() handle = config.init_with_config_and_options(args) questions: dict[str, list[pyalpm.Package]] if args.all: questions = get_all_questions(handle) else: questions = get_questions(handle, args.targets) sep = " " if args.oneline else "\n\t" for target, options in sorted(questions.items()): text = target + ":" for option in options: assert option.db text += sep + option.db.name + "/" + option.name print(text) if __name__ == "__main__": main()