spack uninstall: reduce verbosity with named environments (#34001)

This commit is contained in:
Harmen Stoppels 2023-05-05 10:23:08 +02:00 committed by GitHub
parent bf71b78094
commit 35e1dc8eba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 92 additions and 188 deletions

View File

@ -6,6 +6,7 @@
from __future__ import print_function
import sys
from typing import Dict, List, Optional
from llnl.util import tty
from llnl.util.tty.colify import colify
@ -16,6 +17,7 @@
import spack.error
import spack.package_base
import spack.repo
import spack.spec
import spack.store
import spack.traverse as traverse
from spack.database import InstallStatuses
@ -78,15 +80,19 @@ def setup_parser(subparser):
)
def find_matching_specs(env, specs, allow_multiple_matches=False, force=False, origin=None):
def find_matching_specs(
env: Optional[ev.Environment],
specs: List[spack.spec.Spec],
allow_multiple_matches: bool = False,
origin=None,
) -> List[spack.spec.Spec]:
"""Returns a list of specs matching the not necessarily
concretized specs given from cli
Args:
env (spack.environment.Environment): active environment, or ``None``
if there is not one
specs (list): list of specs to be matched against installed packages
allow_multiple_matches (bool): if True multiple matches are admitted
env: optional active environment
specs: list of specs to be matched against installed packages
allow_multiple_matches: if True multiple matches are admitted
Return:
list: list of specs
@ -128,91 +134,52 @@ def find_matching_specs(env, specs, allow_multiple_matches=False, force=False, o
return specs_from_cli
def installed_runtime_dependents(specs, env):
"""Map each spec to a list of its installed dependents.
def installed_dependents(specs: List[spack.spec.Spec]) -> List[spack.spec.Spec]:
# Note: the combination of arguments (in particular order=breadth
# and root=False) ensures dependents and matching_specs are non-overlapping;
# In the extreme case of "spack uninstall --all" we get the entire database as
# input; in that case we return an empty list.
Args:
specs (list): list of Specs
env (spack.environment.Environment or None): the active environment, or None
def is_installed(spec):
record = spack.store.db.query_local_by_spec_hash(spec.dag_hash())
return record and record.installed
Returns:
tuple: two mappings: one from specs to their dependent installs in the
active environment, and one from specs to dependent installs outside of
the active environment.
specs = traverse.traverse_nodes(
specs,
root=False,
order="breadth",
cover="nodes",
deptype=("link", "run"),
direction="parents",
key=lambda s: s.dag_hash(),
)
Every installed dependent spec is listed once.
If there is not current active environment, the first mapping will be
empty.
"""
active_dpts = {}
outside_dpts = {}
env_hashes = set(env.all_hashes()) if env else set()
# Ensure we stop traversal at input specs.
visited = set(s.dag_hash() for s in specs)
for spec in specs:
for dpt in traverse.traverse_nodes(
spec.dependents(deptype=("link", "run")),
direction="parents",
visited=visited,
deptype=("link", "run"),
root=True,
key=lambda s: s.dag_hash(),
):
hash = dpt.dag_hash()
# Ensure that all the specs we get are installed
record = spack.store.db.query_local_by_spec_hash(hash)
if record is None or not record.installed:
continue
if hash in env_hashes:
active_dpts.setdefault(spec, set()).add(dpt)
else:
outside_dpts.setdefault(spec, set()).add(dpt)
return active_dpts, outside_dpts
return [spec for spec in specs if is_installed(spec)]
def dependent_environments(specs):
"""Map each spec to environments that depend on it.
def dependent_environments(
specs: List[spack.spec.Spec], current_env: Optional[ev.Environment] = None
) -> Dict[ev.Environment, List[spack.spec.Spec]]:
# For each tracked environment, get the specs we would uninstall from it.
# Don't instantiate current environment twice.
env_names = ev.all_environment_names()
if current_env:
env_names = (name for name in env_names if name != current_env.name)
Args:
specs (list): list of Specs
# Mapping from Environment -> non-zero list of specs contained in it.
other_envs_to_specs: Dict[ev.Environment, List[spack.spec.Spec]] = {}
for other_env in (ev.Environment(ev.root(name)) for name in env_names):
specs_in_other_env = all_specs_in_env(other_env, specs)
if specs_in_other_env:
other_envs_to_specs[other_env] = specs_in_other_env
Returns:
dict: mapping from spec to lists of dependent Environments
"""
dependents = {}
for env in ev.all_environments():
hashes = set(env.all_hashes())
for spec in specs:
if spec.dag_hash() in hashes:
dependents.setdefault(spec, []).append(env)
return dependents
return other_envs_to_specs
def inactive_dependent_environments(spec_envs):
"""Strip the active environment from a dependent map.
Take the output of ``dependent_environment()`` and remove the active
environment from all mappings. Remove any specs in the map that now
have no dependent environments. Return the result.
Args:
spec_envs (dict): mapping from spec to lists of dependent Environments
Returns:
dict: mapping from spec to lists of *inactive* dependent Environments
"""
spec_inactive_envs = {}
for spec, de_list in spec_envs.items():
inactive = [de for de in de_list if not de.active]
if inactive:
spec_inactive_envs[spec] = inactive
return spec_inactive_envs
def all_specs_in_env(env: ev.Environment, specs: List[spack.spec.Spec]) -> List[spack.spec.Spec]:
"""Given a list of specs, return those that are in the env"""
hashes = set(env.all_hashes())
return [s for s in specs if s.dag_hash() in hashes]
def _remove_from_env(spec, env):
@ -225,7 +192,7 @@ def _remove_from_env(spec, env):
pass # ignore non-root specs
def do_uninstall(specs, force=False):
def do_uninstall(specs: List[spack.spec.Spec], force: bool = False):
# TODO: get rid of the call-sites that use this function,
# so that we don't have to do a dance of list -> set -> list -> set
hashes_to_remove = set(s.dag_hash() for s in specs)
@ -237,102 +204,56 @@ def do_uninstall(specs, force=False):
spack.package_base.PackageBase.uninstall_by_spec(s, force=force)
def get_uninstall_list(args, specs, env):
"""Returns uninstall_list and remove_list: these may overlap (some things
def get_uninstall_list(args, specs: List[spack.spec.Spec], env: Optional[ev.Environment]):
"""Returns unordered uninstall_list and remove_list: these may overlap (some things
may be both uninstalled and removed from the current environment).
It is assumed we are in an environment if --remove is specified (this
method raises an exception otherwise).
uninstall_list is topologically sorted: dependents come before
dependencies (so if a user uninstalls specs in the order provided,
the dependents will always be uninstalled first).
"""
method raises an exception otherwise)."""
if args.remove and not env:
raise ValueError("Can only use --remove when in an environment")
# Gets the list of installed specs that match the ones given via cli
# args.all takes care of the case where '-a' is given in the cli
base_uninstall_specs = set(find_matching_specs(env, specs, args.all, args.force))
matching_specs = find_matching_specs(env, specs, args.all)
dependent_specs = installed_dependents(matching_specs)
all_uninstall_specs = matching_specs + dependent_specs if args.dependents else matching_specs
other_dependent_envs = dependent_environments(all_uninstall_specs, current_env=env)
active_dpts, outside_dpts = installed_runtime_dependents(base_uninstall_specs, env)
# It will be useful to track the unified set of specs with dependents, as
# well as to separately track specs in the current env with dependents
spec_to_dpts = {}
for spec, dpts in active_dpts.items():
spec_to_dpts[spec] = list(dpts)
for spec, dpts in outside_dpts.items():
if spec in spec_to_dpts:
spec_to_dpts[spec].extend(dpts)
else:
spec_to_dpts[spec] = list(dpts)
# There are dependents and we didn't ask to remove dependents
dangling_dependents = dependent_specs and not args.dependents
all_uninstall_specs = set(base_uninstall_specs)
if args.dependents:
for spec, lst in active_dpts.items():
all_uninstall_specs.update(lst)
for spec, lst in outside_dpts.items():
all_uninstall_specs.update(lst)
# An environment different than the current env depends on
# one or more of the list of all specs to be uninstalled.
dangling_environments = not args.remove and other_dependent_envs
# For each spec that we intend to uninstall, this tracks the set of
# environments outside the current active environment which depend on the
# spec. There may be environments not managed directly with Spack: such
# environments would not be included here.
spec_to_other_envs = inactive_dependent_environments(
dependent_environments(all_uninstall_specs)
)
has_error = not args.force and (
# There are dependents in the current env and we didn't ask to remove
# dependents
(spec_to_dpts and not args.dependents)
# An environment different than the current env (if any) depends on
# one or more of the specs to be uninstalled. There may also be
# packages in those envs which depend on the base set of packages
# to uninstall, but this covers that scenario.
or (not args.remove and spec_to_other_envs)
)
has_error = not args.force and (dangling_dependents or dangling_environments)
if has_error:
# say why each problem spec is needed
specs = set(spec_to_dpts)
specs.update(set(spec_to_other_envs)) # environments depend on this
for i, spec in enumerate(sorted(specs)):
# space out blocks of reasons
if i > 0:
print()
spec_format = "{name}{@version}{%compiler}{/hash:7}"
tty.info("Will not uninstall %s" % spec.cformat(spec_format), format="*r")
dependents = spec_to_dpts.get(spec)
if dependents and not args.dependents:
print("The following packages depend on it:")
spack.cmd.display_specs(dependents, **display_args)
envs = spec_to_other_envs.get(spec)
if envs:
if env:
env_context_qualifier = " other"
else:
env_context_qualifier = ""
print("It is used by the following{0} environments:".format(env_context_qualifier))
colify([e.name for e in envs], indent=4)
msgs = []
if spec_to_dpts and not args.dependents:
tty.info("Refusing to uninstall the following specs")
spack.cmd.display_specs(matching_specs, **display_args)
if dangling_dependents:
print()
tty.info("The following dependents are still installed:")
spack.cmd.display_specs(dependent_specs, **display_args)
msgs.append("use `spack uninstall --dependents` to remove dependents too")
if spec_to_other_envs:
msgs.append("use `spack env remove` to remove from environments")
if dangling_environments:
print()
tty.info("The following environments still reference these specs:")
colify([e.name for e in other_dependent_envs.keys()], indent=4)
msgs.append("use `spack env remove` to remove environments")
msgs.append("use `spack uninstall --force` to override")
print()
tty.die("There are still dependents.", *msgs)
# If we are in an environment, this will track specs in this environment
# which should only be removed from the environment rather than uninstalled
remove_only = set()
remove_only = []
if args.remove and not args.force:
remove_only.update(spec_to_other_envs)
for specs_in_other_env in other_dependent_envs.values():
remove_only.extend(specs_in_other_env)
if remove_only:
tty.info(
"The following specs will be removed but not uninstalled because"
@ -344,23 +265,9 @@ def get_uninstall_list(args, specs, env):
# Compute the set of specs that should be removed from the current env.
# This may overlap (some specs may be uninstalled and also removed from
# the current environment).
if args.remove:
remove_specs = set(base_uninstall_specs)
if args.dependents:
# Any spec matched from the cli, or dependent of, should be removed
# from the environment
for spec, lst in active_dpts.items():
remove_specs.update(lst)
else:
remove_specs = set()
remove_specs = all_specs_in_env(env, all_uninstall_specs) if env and args.remove else []
all_uninstall_specs -= remove_only
# Inefficient topological sort: uninstall dependents before dependencies
all_uninstall_specs = sorted(
all_uninstall_specs, key=lambda x: sum(1 for i in x.traverse()), reverse=True
)
return list(all_uninstall_specs), list(remove_specs)
return list(set(all_uninstall_specs) - set(remove_only)), remove_specs
def uninstall_specs(args, specs):
@ -387,13 +294,13 @@ def uninstall_specs(args, specs):
env.regenerate_views()
def confirm_removal(specs):
def confirm_removal(specs: List[spack.spec.Spec]):
"""Display the list of specs to be removed and ask for confirmation.
Args:
specs (list): specs to be removed
specs: specs to be removed
"""
tty.msg("The following packages will be uninstalled:\n")
tty.msg("The following {} packages will be uninstalled:\n".format(len(specs)))
spack.cmd.display_specs(specs, **display_args)
print("")
answer = tty.get_yes_or_no("Do you want to proceed?", default=False)

View File

@ -229,7 +229,7 @@ def deactivate():
_active_environment = None
def active_environment():
def active_environment() -> Optional["Environment"]:
"""Returns the active environment when there is any"""
return _active_environment

View File

@ -402,8 +402,9 @@ def parse(self, initial_spec: spack.spec.Spec) -> spack.spec.Spec:
elif not self.has_hash and self.ctx.accept(TokenType.DAG_HASH):
dag_hash = self.ctx.current_token.value[1:]
matches = []
if spack.environment.active_environment():
matches = spack.environment.active_environment().get_by_hash(dag_hash)
active_env = spack.environment.active_environment()
if active_env:
matches = active_env.get_by_hash(dag_hash)
if not matches:
matches = spack.store.db.get_by_hash(dag_hash)
if not matches:

View File

@ -1049,7 +1049,7 @@ def test_env_blocks_uninstall(mock_stage, mock_fetch, install_mockery):
out = uninstall("mpileaks", fail_on_error=False)
assert uninstall.returncode == 1
assert "used by the following environments" in out
assert "The following environments still reference these specs" in out
def test_roots_display_with_variants():

View File

@ -3,7 +3,6 @@
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import itertools
import sys
import pytest
@ -58,14 +57,11 @@ def test_correct_installed_dependents(mutable_database):
callpath.package.do_uninstall(force=True)
# Retrieve all dependent hashes
inside_dpts, outside_dpts = spack.cmd.uninstall.installed_runtime_dependents(
dependencies, None
)
dependent_hashes = [s.dag_hash() for s in itertools.chain(*outside_dpts.values())]
set_dependent_hashes = set(dependent_hashes)
dependents = spack.cmd.uninstall.installed_dependents(dependencies)
assert dependents
# We dont have an env, so this should be empty.
assert not inside_dpts
dependent_hashes = [s.dag_hash() for s in dependents]
set_dependent_hashes = set(dependent_hashes)
# Assert uniqueness
assert len(dependent_hashes) == len(set_dependent_hashes)