feat: UninstallQueue

This commit is contained in:
relikd
2025-09-01 12:49:25 +02:00
parent 031cc8543d
commit 9ac2e4aac8

194
brew.py
View File

@@ -300,72 +300,22 @@ def cli_install(args: ArgParams) -> None:
# https://docs.brew.sh/Manpage#uninstall-remove-rm-options-installed_formulainstalled_cask-
def cli_uninstall(args: ArgParams) -> None:
''' Remove / uninstall a package. '''
depTree = Cellar.getDependencyTree()
depTree.forward.assertExist(args.packages + args.ignore)
recipe = depTree.collectUninstall(
args.packages, args.ignore, ignoreDependencies=args.no_dependencies)
# hard-fail check. no direct dependencies
if not args.force and recipe.warnings:
for pkg, deps in recipe.warnings:
if args.leaves:
deps = depTree.reverse.getLeaves(pkg).difference(
args.packages, args.ignore)
Log.error('{} is a {}dependency of {}'.format(
pkg, '' if args.leaves else 'direct ', ', '.join(deps)))
exit(1)
needsUninstall = sorted(recipe.remove)
queue = UninstallQueue()
queue.collect(args.packages, args.ignore, leaves=args.leaves,
ignoreDependencies=args.no_dependencies)
if not args.force:
# hard-fail check. no direct dependencies
queue.validateQueue()
# show potential changes
if not args.dry_run:
for pkg in needsUninstall:
Log.main(f'==> will remove {pkg}.')
queue.printUninstallQueue()
# soft-fail check. warning for any doubly used dependencies
for pkg in sorted(recipe.skip):
if args.leaves:
deps = depTree.reverse.getLeaves(pkg)
else:
deps = depTree.reverse.direct[pkg]
Log.warn(f'skip {pkg}. used by:',
', '.join(deps.difference(recipe.remove, args.ignore)))
# if interactive, show potential changes and ask user to continue
if args.dry_run or args.yes:
pass
elif not Utils.ask('Do you want to continue?', 'n'):
queue.printSkipped()
# if interactive, ask user to continue
if args.dry_run or args.yes or Utils.ask('Do you want to continue?', 'n'):
queue.uninstall(dryRun=args.dry_run)
else:
Log.info('abort.')
return
# delete links
Log.info('==> Remove symlinks for', len(needsUninstall), 'packages')
count = 0
for pkg in needsUninstall:
count += len(Cellar.unlinkPackage(
pkg, dryRun=args.dry_run, quiet=args.dry_run and Log.LEVEL <= 2))
Log.main('Would remove' if args.dry_run else 'Removed', count, 'symlinks')
# delete packages and links
Log.info('==> Uninstall', len(needsUninstall), 'packages')
total_savings = 0
for pkg in needsUninstall:
path = Cellar.installPath(pkg)
total_savings += File.remove(path, dryRun=args.dry_run)
Log.info('==> This operation {} approximately {} of disk space.'.format(
'would free' if args.dry_run else 'has freed',
Utils.humanSize(total_savings)))
if args.dry_run:
print()
print('The following packages will be removed:')
Utils.printInColumns(needsUninstall)
if recipe.skip:
print()
print('The following packages will NOT be removed:')
Utils.printInColumns(sorted(recipe.skip))
# https://docs.brew.sh/Manpage#link-ln-options-installed_formula-
@@ -499,7 +449,7 @@ def cli_cleanup(args: ArgParams) -> None:
if not os.path.exists(link.target):
total_savings += File.remove(link.path, dryRun=args.dry_run)
Log.main('==> This operation {} approximately {} of disk space.'.format(
Log.main('==> This operation {} approximately {} of disk space'.format(
'would free' if args.dry_run else 'has freed',
Utils.humanSize(total_savings)))
@@ -1609,6 +1559,124 @@ class Fixer:
os.utime(fname, (atime, mtime))
# -----------------------------------
# UninstallQueue
# -----------------------------------
class UninstallQueue:
def __init__(self) -> None:
# uses after uninstall (primary dependencies with multiple parents)
self.warnings = {} # type: dict[str, set[str]] # {pkg: {deps}}
# used by other packages (secondary dependencies with multiple parents)
self.skips = {} # type: dict[str, set[str]] # {pkg: {deps}}
# list of packages that will be removed
self.uninstallQueue = [] # type: list[str]
def collect(
self, deletePkgs: list[str], hiddenPkgs: list[str], *,
leaves: bool, ignoreDependencies: bool,
) -> None:
'''
Try to uninstall all `deletePkgs`. Act as if `hiddenPkgs` don't exist.
Any package that depends on another package (not in those two sets)
will be skipped and remains on the system.
'''
tree = Cellar.getDependencyTree()
tree.forward.assertExist(deletePkgs + hiddenPkgs)
def getDeps(pkg: str) -> set[str]:
if leaves:
return tree.reverse.getLeaves(pkg)
else:
return tree.reverse.direct[pkg]
def setWarnings(hidden: set[str]) -> None:
self.warnings = {pkg: deps for pkg in deletePkgs
if (deps := getDeps(pkg) - hidden)}
# user said "these aren't the packages you're looking for"
activelyIgnored = tree.obsolete(hiddenPkgs)
if ignoreDependencies:
setWarnings(activelyIgnored.union(deletePkgs))
self.uninstallQueue = deletePkgs # TODO: copy?
self.skips = {}
return
# ideally, we uninstall <deletePkgs> and all its dependencies
rawUninstall = tree.forward.unionAll(deletePkgs)
# dont consider these, they will be gone (or are actively ignored)
hidden = activelyIgnored.union(rawUninstall)
# only secondary items can be skipped, primary are always removed
secondary = rawUninstall.difference(deletePkgs)
# skip a package if it has other, non-ignored, parents
skipped = tree.reverse.filterDifference(secondary, hidden)
removed = rawUninstall.difference(skipped)
# recursively ignore dependencies that rely on already ignored
while deps := tree.reverse.filterIntersection(removed, skipped):
skipped.update(deps)
removed.difference_update(deps)
# remove any not-installed packages
removed -= tree.forward.missing(removed)
setWarnings(hidden)
self.uninstallQueue = sorted(removed)
irrelevant = removed.union(hiddenPkgs)
self.skips = {pkg: deps for pkg in skipped
if (deps := getDeps(pkg) - irrelevant)}
def validateQueue(self) -> None:
''' Check for direct dependencies. If found, fail with exit code 1 '''
if self.warnings:
for pkg, deps in sorted(self.warnings.items()):
Log.error(pkg, 'is a dependency of', ', '.join(sorted(deps)))
exit(1)
def printUninstallQueue(self) -> None:
for pkg in self.uninstallQueue:
Log.main(f'==> will remove {pkg}.')
def printSkipped(self) -> None:
for pkg, deps in sorted(self.skips.items()):
Log.warn(f'skip {pkg}. used by:', ', '.join(sorted(deps)))
def uninstall(self, *, dryRun: bool) -> None:
countPkgs = len(self.uninstallQueue)
# delete links
Log.info('==> Remove symlinks for', countPkgs, 'packages')
countSym = 0
for pkg in self.uninstallQueue:
links = Cellar.unlinkPackage(
pkg, dryRun=dryRun, quiet=dryRun and Log.LEVEL <= 2)
countSym += len(links)
Log.main('Would remove' if dryRun else 'Removed', countSym, 'symlinks')
# delete packages and links
Log.info('==> Uninstall', countPkgs, 'packages')
total_savings = 0
for pkg in self.uninstallQueue:
path = Cellar.installPath(pkg)
total_savings += File.remove(path, dryRun=dryRun)
Log.info('==> This operation {} approximately {} of disk space'.format(
'would free' if dryRun else 'has freed',
Utils.humanSize(total_savings)))
if dryRun:
print()
print('The following packages will be removed:')
Utils.printInColumns(self.uninstallQueue)
if self.skips:
print()
print('The following packages will NOT be removed:')
Utils.printInColumns(sorted(self.skips))
# -----------------------------------
# RubyParser
# -----------------------------------