ref: class DependencyTree
This commit is contained in:
156
brew.py
156
brew.py
@@ -29,7 +29,7 @@ from argparse import (
|
|||||||
_MutuallyExclusiveGroup as ArgsXorGroup,
|
_MutuallyExclusiveGroup as ArgsXorGroup,
|
||||||
)
|
)
|
||||||
from typing import (
|
from typing import (
|
||||||
Any, Callable, Iterable, Iterator, NamedTuple, Optional, TypedDict, TypeVar
|
Any, Callable, Iterator, NamedTuple, Optional, TypedDict, TypeVar
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -381,63 +381,38 @@ def cli_uninstall(args: ArgParams) -> None:
|
|||||||
Log.error('unknown package:', ', '.join(wrongPkg))
|
Log.error('unknown package:', ', '.join(wrongPkg))
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
# ignore user-defined dependency constraints
|
recipe = depTree.collectUninstall(
|
||||||
hidden = depTree.forward.unionAll(args.ignore)
|
args.packages, args.ignore, ignoreDependencies=args.no_dependencies)
|
||||||
unhidden = depTree.reverse.filter(
|
|
||||||
hidden, lambda x: x.difference(hidden, args.ignore))
|
|
||||||
hidden = hidden.union(args.ignore) - unhidden
|
|
||||||
|
|
||||||
# check dependencies after uninstall
|
|
||||||
uninstallAll = depTree.forward.unionAll(args.packages).union(args.packages)
|
|
||||||
usesAfterUninstall = {
|
|
||||||
pkg: depTree.reverse.direct[pkg].difference(uninstallAll, hidden)
|
|
||||||
for pkg in uninstallAll
|
|
||||||
}
|
|
||||||
|
|
||||||
# hard-fail check. no direct dependencies
|
# hard-fail check. no direct dependencies
|
||||||
directDepends = [pkg for pkg in args.packages if usesAfterUninstall[pkg]]
|
if not args.force and recipe.warnings:
|
||||||
if directDepends:
|
for pkg, deps in recipe.warnings:
|
||||||
for pkg in directDepends:
|
|
||||||
if args.leaves:
|
if args.leaves:
|
||||||
deps = depTree.reverse.getLeaves(pkg)
|
deps = depTree.reverse.getLeaves(pkg).difference(
|
||||||
else:
|
args.packages, args.ignore)
|
||||||
deps = usesAfterUninstall[pkg]
|
|
||||||
Log.error('{} is a {}dependency of {}'.format(
|
Log.error('{} is a {}dependency of {}'.format(
|
||||||
pkg, '' if args.leaves else 'direct ', ', '.join(deps)))
|
pkg, '' if args.leaves else 'direct ', ', '.join(deps)))
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
# prepare working set
|
needsUninstall = sorted(recipe.remove)
|
||||||
if args.no_dependencies:
|
|
||||||
ignored = set()
|
|
||||||
remaining = set(args.packages)
|
|
||||||
else:
|
|
||||||
ignored = set(pkg for pkg, deps in usesAfterUninstall.items() if deps)
|
|
||||||
remaining = uninstallAll.difference(ignored)
|
|
||||||
|
|
||||||
# recursively ignore dependencies that rely on already ignored dependencies
|
|
||||||
while xx := depTree.reverse.filter(remaining, lambda x: x & ignored):
|
|
||||||
ignored.update(xx)
|
|
||||||
remaining.difference_update(xx)
|
|
||||||
|
|
||||||
# remove any not-installed packages
|
|
||||||
remaining = remaining.difference(depTree.forward.missing(remaining))
|
|
||||||
needsUninstall = sorted(remaining)
|
|
||||||
|
|
||||||
# if not dry-run, show potential changes
|
# if not dry-run, show potential changes
|
||||||
for pkg in [] if args.dry_run else needsUninstall:
|
for pkg in [] if args.dry_run else needsUninstall:
|
||||||
Log.main(f'==> will remove {pkg}.')
|
Log.main(f'==> will remove {pkg}.')
|
||||||
|
|
||||||
# soft-fail check. warning for any doubly used dependencies
|
# soft-fail check. warning for any doubly used dependencies
|
||||||
for pkg in sorted(ignored):
|
for pkg in sorted(recipe.skip):
|
||||||
if args.leaves:
|
if args.leaves:
|
||||||
deps = depTree.reverse.getLeaves(pkg)
|
deps = depTree.reverse.getLeaves(pkg)
|
||||||
else:
|
else:
|
||||||
deps = depTree.reverse.direct[pkg].difference(remaining)
|
deps = depTree.reverse.direct[pkg]
|
||||||
Log.warn(f'skip {pkg}. used by:',
|
Log.warn(f'skip {pkg}. used by:',
|
||||||
', '.join(deps.difference(args.ignore)))
|
', '.join(deps.difference(recipe.remove, args.ignore)))
|
||||||
|
|
||||||
# if not dry-run, show potential changes and ask user to continue
|
# if interactive, show potential changes and ask user to continue
|
||||||
if not args.dry_run and not Utils.ask('Do you want to continue?', 'n'):
|
if args.dry_run or args.yes:
|
||||||
|
pass
|
||||||
|
elif not Utils.ask('Do you want to continue?', 'n'):
|
||||||
Log.info('abort.')
|
Log.info('abort.')
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -464,10 +439,10 @@ def cli_uninstall(args: ArgParams) -> None:
|
|||||||
print()
|
print()
|
||||||
print('The following packages will be removed:')
|
print('The following packages will be removed:')
|
||||||
Utils.printInColumns(needsUninstall)
|
Utils.printInColumns(needsUninstall)
|
||||||
if ignored:
|
if recipe.skip:
|
||||||
print()
|
print()
|
||||||
print('The following packages will NOT be removed:')
|
print('The following packages will NOT be removed:')
|
||||||
Utils.printInColumns(sorted(ignored))
|
Utils.printInColumns(sorted(recipe.skip))
|
||||||
|
|
||||||
|
|
||||||
# https://docs.brew.sh/Manpage#link-ln-options-installed_formula-
|
# https://docs.brew.sh/Manpage#link-ln-options-installed_formula-
|
||||||
@@ -716,6 +691,9 @@ def parseArgs() -> ArgParams:
|
|||||||
# uninstall
|
# uninstall
|
||||||
cmd = cli.subcommand('uninstall', cli_uninstall, aliases=['remove', 'rm'])
|
cmd = cli.subcommand('uninstall', cli_uninstall, aliases=['remove', 'rm'])
|
||||||
cmd.arg('packages', nargs='+', help='Brew package name')
|
cmd.arg('packages', nargs='+', help='Brew package name')
|
||||||
|
cmd.arg_bool('-y', '--yes', help='Do not ask for confirmation')
|
||||||
|
cmd.arg_bool('-f', '--force', help='''
|
||||||
|
Remove package even if it is a direct dependency of another package''')
|
||||||
cmd.arg('--ignore', nargs='*', default=[], help='''
|
cmd.arg('--ignore', nargs='*', default=[], help='''
|
||||||
Treat IGNORE packages as if they are not installed.
|
Treat IGNORE packages as if they are not installed.
|
||||||
Allow uninstall of packages which are dependency of IGNORE package.''')
|
Allow uninstall of packages which are dependency of IGNORE package.''')
|
||||||
@@ -928,10 +906,15 @@ class TreeDict:
|
|||||||
rv = set(x for key in keys for x in self.getAll(key))
|
rv = set(x for key in keys for x in self.getAll(key))
|
||||||
return rv.union(keys) if inclInput else rv
|
return rv.union(keys) if inclInput else rv
|
||||||
|
|
||||||
def filter(self, keys: Iterable[str], fn: Callable[[set[str]], Any]) \
|
def filterDifference(self, keys: Keys, other: set[str]) -> Keys:
|
||||||
-> set[str]:
|
''' Filter keys based on `if .direct.get(key).difference(other)` '''
|
||||||
''' Filter keys based on `fn(x)` where `x` is `.direct[x]` '''
|
return type(keys)(key for key in keys
|
||||||
return set(key for key in keys if fn(self.direct.get(key) or set()))
|
if self.direct.get(key, set()).difference(other))
|
||||||
|
|
||||||
|
def filterIntersection(self, keys: Keys, other: set[str]) -> Keys:
|
||||||
|
''' Filter keys based on `if .direct.get(key).intersection(other)` '''
|
||||||
|
return type(keys)(key for key in keys
|
||||||
|
if self.direct.get(key, set()).intersection(other))
|
||||||
|
|
||||||
def missing(self, keys: Keys) -> Keys:
|
def missing(self, keys: Keys) -> Keys:
|
||||||
''' List of keys which are not present in `.direct` (keeps order) '''
|
''' List of keys which are not present in `.direct` (keeps order) '''
|
||||||
@@ -981,6 +964,81 @@ class TreeDict:
|
|||||||
print('}')
|
print('}')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
# DependencyTree
|
||||||
|
# -----------------------------------
|
||||||
|
|
||||||
|
class DependencyTree:
|
||||||
|
def __init__(self, forward: TreeDict) -> None:
|
||||||
|
self.forward = forward
|
||||||
|
self.reverse = forward.inverse()
|
||||||
|
|
||||||
|
def obsolete(self, ignore: list[str]) -> set[str]:
|
||||||
|
'''
|
||||||
|
Packages that would become obsolete if `ignore` doesn't exist
|
||||||
|
(incl. `ignore`)
|
||||||
|
'''
|
||||||
|
if not ignore:
|
||||||
|
return set()
|
||||||
|
# going DOWN the tree, get all dependencies of <ignore>
|
||||||
|
allIgnored = self.forward.unionAll(ignore, inclInput=True)
|
||||||
|
# yes, add ignore then difference because <ignore> can be nested
|
||||||
|
children = allIgnored.difference(ignore)
|
||||||
|
# going UP the tree and selecting branches not already ignored.
|
||||||
|
# => look for children with other parents besides <ignore>
|
||||||
|
multiParents = self.reverse.filterDifference(children, allIgnored)
|
||||||
|
return allIgnored - multiParents
|
||||||
|
|
||||||
|
class UninstallRecipe(NamedTuple):
|
||||||
|
remove: set[str]
|
||||||
|
skip: set[str]
|
||||||
|
warnings: list[tuple[str, set[str]]] # [(pkg, {deps})]
|
||||||
|
|
||||||
|
def collectUninstall(
|
||||||
|
self, deletePkgs: list[str], hiddenPkgs: list[str],
|
||||||
|
*, ignoreDependencies: bool
|
||||||
|
) -> UninstallRecipe:
|
||||||
|
'''
|
||||||
|
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.
|
||||||
|
'''
|
||||||
|
def warnings(hidden: set[str]) -> list[tuple]:
|
||||||
|
# uses after uninstall (dependencies with multiple parents)
|
||||||
|
return [(pkg, deps) for pkg in deletePkgs
|
||||||
|
if (deps := self.reverse.direct[pkg] - hidden)]
|
||||||
|
|
||||||
|
# user said "these aren't the packages you're looking for"
|
||||||
|
activelyIgnored = self.obsolete(hiddenPkgs)
|
||||||
|
|
||||||
|
if ignoreDependencies:
|
||||||
|
hidden = activelyIgnored.union(deletePkgs)
|
||||||
|
return self.UninstallRecipe(
|
||||||
|
set(deletePkgs), set(), warnings(hidden))
|
||||||
|
|
||||||
|
# ideally, we uninstall <deletePkgs> and all its dependencies
|
||||||
|
rawUninstall = self.forward.unionAll(deletePkgs, inclInput=True)
|
||||||
|
|
||||||
|
# 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 = self.reverse.filterDifference(secondary, hidden)
|
||||||
|
removed = rawUninstall.difference(skipped)
|
||||||
|
|
||||||
|
# recursively ignore dependencies that rely on already ignored
|
||||||
|
while deps := self.reverse.filterIntersection(removed, skipped):
|
||||||
|
skipped.update(deps)
|
||||||
|
removed.difference_update(deps)
|
||||||
|
|
||||||
|
# remove any not-installed packages
|
||||||
|
removed -= self.forward.missing(removed)
|
||||||
|
|
||||||
|
return self.UninstallRecipe(removed, skipped, warnings(hidden))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Remote logic
|
# Remote logic
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
@@ -1240,10 +1298,6 @@ class Cellar:
|
|||||||
Cellar.linkPackage(pkg, version, noExe=not withBin)
|
Cellar.linkPackage(pkg, version, noExe=not withBin)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
class DependencyTree(NamedTuple):
|
|
||||||
forward: TreeDict
|
|
||||||
reverse: TreeDict
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def getDependencyTree() -> DependencyTree:
|
def getDependencyTree() -> DependencyTree:
|
||||||
''' Returns dict object for dependency traversal '''
|
''' Returns dict object for dependency traversal '''
|
||||||
@@ -1254,7 +1308,7 @@ class Cellar:
|
|||||||
for ver in info.verAll
|
for ver in info.verAll
|
||||||
for dep in Cellar.getDependencies(info.package, ver) or []
|
for dep in Cellar.getDependencies(info.package, ver) or []
|
||||||
)
|
)
|
||||||
return Cellar.DependencyTree(forward, forward.inverse())
|
return DependencyTree(forward)
|
||||||
|
|
||||||
# Symlink processing
|
# Symlink processing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user