New command, spack change, to change existing env specs (#31995)

If you have an environment like

```
$ cat spack.yaml
spack:
  specs: [openmpi@4.1.0+cuda]
```

this PR provides a new command `spack change` that you can use to adjust environment specs from the command line:

```
$ spack change openmpi~cuda
$ cat spack.yaml
spack:
  specs: [openmpi@4.1.0~cuda]
```

in other words, this allows you to tweak the details of environment specs from the command line.

Notes:

* This is only allowed for environments that do not define matrices
  * This is possible but not anticipated to be needed immediately
  * If this were done, it should probably only be done for "named"/not-anonymous specs (i.e. we can change `openmpi+cuda` but not spec like `+cuda` or `@4.0.1~cuda`)
This commit is contained in:
Peter Scheibel 2022-09-01 11:04:01 -07:00 committed by GitHub
parent 92b72f186e
commit 2968ae667f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 273 additions and 4 deletions

View File

@ -0,0 +1,51 @@
# Copyright 2013-2022 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import spack.cmd
import spack.cmd.common.arguments as arguments
description = "change an existing spec in an environment"
section = "environments"
level = "long"
def setup_parser(subparser):
subparser.add_argument(
"-l",
"--list-name",
dest="list_name",
default="specs",
help="name of the list to remove specs from",
)
subparser.add_argument(
"--match-spec",
dest="match_spec",
help="if name is ambiguous, supply a spec to match",
)
subparser.add_argument(
"-a",
"--all",
action="store_true",
help="change all matching specs (allow changing more than one spec)",
)
arguments.add_common_arguments(subparser, ["specs"])
def change(parser, args):
env = spack.cmd.require_active_env(cmd_name="change")
with env.write_transaction():
if args.match_spec:
match_spec = spack.spec.Spec(args.match_spec)
else:
match_spec = None
for spec in spack.cmd.parse_specs(args.specs):
env.change_existing_spec(
spec,
list_name=args.list_name,
match_spec=match_spec,
allow_changing_multiple_specs=args.all,
)
env.write()

View File

@ -1073,6 +1073,58 @@ def add(self, user_spec, list_name=user_speclist_name):
return bool(not existing) return bool(not existing)
def change_existing_spec(
self,
change_spec,
list_name=user_speclist_name,
match_spec=None,
allow_changing_multiple_specs=False,
):
"""
Find the spec identified by `match_spec` and change it to `change_spec`.
Arguments:
change_spec (spack.spec.Spec): defines the spec properties that
need to be changed. This will not change attributes of the
matched spec unless they conflict with `change_spec`.
list_name (str): identifies the spec list in the environment that
should be modified
match_spec (spack.spec.Spec): if set, this identifies the spec
that should be changed. If not set, it is assumed we are
looking for a spec with the same name as `change_spec`.
"""
if not (change_spec.name or (match_spec and match_spec.name)):
raise ValueError(
"Must specify a spec name to identify a single spec"
" in the environment that will be changed"
)
match_spec = match_spec or Spec(change_spec.name)
list_to_change = self.spec_lists[list_name]
if list_to_change.is_matrix:
raise SpackEnvironmentError(
"Cannot directly change specs in matrices:"
" specify a named list that is not a matrix"
)
matches = list(x for x in list_to_change if x.satisfies(match_spec))
if len(matches) == 0:
raise ValueError(
"There are no specs named {0} in {1}".format(match_spec.name, list_name)
)
elif len(matches) > 1 and not allow_changing_multiple_specs:
raise ValueError("{0} matches multiple specs".format(str(match_spec)))
new_speclist = SpecList(list_name)
for i, spec in enumerate(list_to_change):
if spec.satisfies(match_spec):
new_speclist.add(Spec.override(spec, change_spec))
else:
new_speclist.add(spec)
self.spec_lists[list_name] = new_speclist
self.update_stale_references()
def remove(self, query_spec, list_name=user_speclist_name, force=False): def remove(self, query_spec, list_name=user_speclist_name, force=False):
"""Remove specs from an environment that match a query_spec""" """Remove specs from an environment that match a query_spec"""
query_spec = Spec(query_spec) query_spec = Spec(query_spec)

View File

@ -284,6 +284,22 @@ def _string_or_none(s):
self.platform, self.os, self.target = platform_tuple self.platform, self.os, self.target = platform_tuple
@staticmethod
def override(init_spec, change_spec):
if init_spec:
new_spec = init_spec.copy()
else:
new_spec = ArchSpec()
if change_spec.platform:
new_spec.platform = change_spec.platform
# TODO: if the platform is changed to something that is incompatible
# with the current os, we should implicitly remove it
if change_spec.os:
new_spec.os = change_spec.os
if change_spec.target:
new_spec.target = change_spec.target
return new_spec
def _autospec(self, spec_like): def _autospec(self, spec_like):
if isinstance(spec_like, ArchSpec): if isinstance(spec_like, ArchSpec):
return spec_like return spec_like
@ -2242,6 +2258,33 @@ def read_yaml_dep_specs(deps, hash_type=ht.dag_hash.name):
raise spack.error.SpecError("Couldn't parse dependency types in spec.") raise spack.error.SpecError("Couldn't parse dependency types in spec.")
yield dep_name, dep_hash, list(deptypes), hash_type yield dep_name, dep_hash, list(deptypes), hash_type
@staticmethod
def override(init_spec, change_spec):
# TODO: this doesn't account for the case where the changed spec
# (and the user spec) have dependencies
new_spec = init_spec.copy()
package_cls = spack.repo.path.get_pkg_class(new_spec.name)
if change_spec.versions and not change_spec.versions == spack.version.ver(":"):
new_spec.versions = change_spec.versions
for variant, value in change_spec.variants.items():
if variant in package_cls.variants:
if variant in new_spec.variants:
new_spec.variants.substitute(value)
else:
new_spec.variants[variant] = value
else:
raise ValueError("{0} is not a variant of {1}".format(variant, new_spec.name))
if change_spec.compiler:
new_spec.compiler = change_spec.compiler
if change_spec.compiler_flags:
for flagname, flagvals in change_spec.compiler_flags.items():
new_spec.compiler_flags[flagname] = flagvals
if change_spec.architecture:
new_spec.architecture = ArchSpec.override(
new_spec.architecture, change_spec.architecture
)
return new_spec
@staticmethod @staticmethod
def from_literal(spec_dict, normal=True): def from_literal(spec_dict, normal=True):
"""Builds a Spec from a dictionary containing the spec literal. """Builds a Spec from a dictionary containing the spec literal.

View File

@ -34,6 +34,13 @@ def __init__(self, name="specs", yaml_list=None, reference=None):
self._constraints = None self._constraints = None
self._specs = None self._specs = None
@property
def is_matrix(self):
for item in self.specs_as_yaml_list:
if isinstance(item, dict):
return True
return False
@property @property
def specs_as_yaml_list(self): def specs_as_yaml_list(self):
if self._expanded_list is None: if self._expanded_list is None:

View File

@ -43,6 +43,7 @@
env = SpackCommand("env") env = SpackCommand("env")
install = SpackCommand("install") install = SpackCommand("install")
add = SpackCommand("add") add = SpackCommand("add")
change = SpackCommand("change")
remove = SpackCommand("remove") remove = SpackCommand("remove")
concretize = SpackCommand("concretize") concretize = SpackCommand("concretize")
stage = SpackCommand("stage") stage = SpackCommand("stage")
@ -71,6 +72,35 @@ def test_add():
assert Spec("mpileaks") in e.user_specs assert Spec("mpileaks") in e.user_specs
def test_change_match_spec():
env("create", "test")
e = ev.read("test")
with e:
add("mpileaks@2.1")
add("mpileaks@2.2")
change("--match-spec", "mpileaks@2.2", "mpileaks@2.3")
assert not any(x.satisfies("mpileaks@2.2") for x in e.user_specs)
assert any(x.satisfies("mpileaks@2.3") for x in e.user_specs)
def test_change_multiple_matches():
env("create", "test")
e = ev.read("test")
with e:
add("mpileaks@2.1")
add("mpileaks@2.2")
add("libelf@0.8.12%clang")
change("--match-spec", "mpileaks", "-a", "mpileaks%gcc")
assert all(x.satisfies("%gcc") for x in e.user_specs if x.name == "mpileaks")
assert any(x.satisfies("%clang") for x in e.user_specs if x.name == "libelf")
def test_env_add_virtual(): def test_env_add_virtual():
env("create", "test") env("create", "test")

View File

@ -4,16 +4,19 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT) # SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Test environment internals without CLI""" """Test environment internals without CLI"""
import sys
import pytest import pytest
from six import StringIO
import spack.environment as ev import spack.environment as ev
import spack.spec import spack.spec
pytestmark = pytest.mark.skipif(
@pytest.mark.skipif( sys.platform == "win32", reason="Envs are not supported on windows"
str(spack.platforms.host()) == "windows", reason="Not supported on Windows (yet)"
) )
def test_hash_change_no_rehash_concrete(tmpdir, mock_packages, config): def test_hash_change_no_rehash_concrete(tmpdir, mock_packages, config):
# create an environment # create an environment
env_path = tmpdir.mkdir("env_dir").strpath env_path = tmpdir.mkdir("env_dir").strpath
@ -43,6 +46,66 @@ def test_hash_change_no_rehash_concrete(tmpdir, mock_packages, config):
assert read_in.specs_by_hash[read_in.concretized_order[0]]._hash == new_hash assert read_in.specs_by_hash[read_in.concretized_order[0]]._hash == new_hash
def test_env_change_spec(tmpdir, mock_packages, config):
env_path = tmpdir.mkdir("env_dir").strpath
env = ev.Environment(env_path)
env.write()
spec = spack.spec.Spec("mpileaks@2.1~shared+debug")
env.add(spec)
env.write()
change_spec = spack.spec.Spec("mpileaks@2.2")
env.change_existing_spec(change_spec)
(spec,) = env.added_specs()
assert spec == spack.spec.Spec("mpileaks@2.2~shared+debug")
change_spec = spack.spec.Spec("mpileaks~debug")
env.change_existing_spec(change_spec)
(spec,) = env.added_specs()
assert spec == spack.spec.Spec("mpileaks@2.2~shared~debug")
_test_matrix_yaml = """\
env:
definitions:
- compilers: ["%gcc", "%clang"]
- desired_specs: ["mpileaks@2.1"]
specs:
- matrix:
- [$compilers]
- [$desired_specs]
"""
def test_env_change_spec_in_definition(tmpdir, mock_packages, config, mutable_mock_env_path):
initial_yaml = StringIO(_test_matrix_yaml)
e = ev.create("test", initial_yaml)
e.concretize()
e.write()
assert any(x.satisfies("mpileaks@2.1%gcc") for x in e.user_specs)
e.change_existing_spec(spack.spec.Spec("mpileaks@2.2"), list_name="desired_specs")
e.write()
assert any(x.satisfies("mpileaks@2.2%gcc") for x in e.user_specs)
assert not any(x.satisfies("mpileaks@2.1%gcc") for x in e.user_specs)
def test_env_change_spec_in_matrix_raises_error(
tmpdir, mock_packages, config, mutable_mock_env_path
):
initial_yaml = StringIO(_test_matrix_yaml)
e = ev.create("test", initial_yaml)
e.concretize()
e.write()
with pytest.raises(spack.environment.SpackEnvironmentError) as error:
e.change_existing_spec(spack.spec.Spec("mpileaks@2.2"))
assert "Cannot directly change specs in matrices" in str(error)
def test_activate_should_require_an_env(): def test_activate_should_require_an_env():
with pytest.raises(TypeError): with pytest.raises(TypeError):
ev.activate(env="name") ev.activate(env="name")

View File

@ -1111,6 +1111,20 @@ def test_splice_swap_names_mismatch_virtuals(self, transitive):
with pytest.raises(spack.spec.SpliceError, match="will not provide the same virtuals."): with pytest.raises(spack.spec.SpliceError, match="will not provide the same virtuals."):
spec.splice(dep, transitive) spec.splice(dep, transitive)
def test_spec_override(self):
init_spec = Spec("a foo=baz foobar=baz cflags=-O3 cxxflags=-O1")
change_spec = Spec("a foo=fee cflags=-O2")
new_spec = Spec.override(init_spec, change_spec)
new_spec.concretize()
assert "foo=fee" in new_spec
# This check fails without concretizing: apparently if both specs are
# abstract, then the spec will always be considered to satisfy
# 'variant=value' (regardless of whether it in fact does).
assert "foo=baz" not in new_spec
assert "foobar=baz" in new_spec
assert new_spec.compiler_flags["cflags"] == ["-O2"]
assert new_spec.compiler_flags["cxxflags"] == ["-O1"]
@pytest.mark.regression("3887") @pytest.mark.regression("3887")
@pytest.mark.parametrize("spec_str", ["git", "hdf5", "py-flake8"]) @pytest.mark.parametrize("spec_str", ["git", "hdf5", "py-flake8"])

View File

@ -337,7 +337,7 @@ _spack() {
then then
SPACK_COMPREPLY="-h --help -H --all-help --color -c --config -C --config-scope -d --debug --timestamp --pdb -e --env -D --env-dir -E --no-env --use-env-repo -k --insecure -l --enable-locks -L --disable-locks -m --mock -b --bootstrap -p --profile --sorted-profile --lines -v --verbose --stacktrace -V --version --print-shell-vars" SPACK_COMPREPLY="-h --help -H --all-help --color -c --config -C --config-scope -d --debug --timestamp --pdb -e --env -D --env-dir -E --no-env --use-env-repo -k --insecure -l --enable-locks -L --disable-locks -m --mock -b --bootstrap -p --profile --sorted-profile --lines -v --verbose --stacktrace -V --version --print-shell-vars"
else else
SPACK_COMPREPLY="activate add arch audit blame bootstrap build-env buildcache cd checksum ci clean clone commands compiler compilers concretize config containerize create deactivate debug dependencies dependents deprecate dev-build develop diff docs edit env extensions external fetch find gc gpg graph help info install license list load location log-parse maintainers make-installer mark mirror module patch pkg providers pydoc python reindex remove rm repo resource restage solve spec stage style tags test test-env tutorial undevelop uninstall unit-test unload url verify versions view" SPACK_COMPREPLY="activate add arch audit blame bootstrap build-env buildcache cd change checksum ci clean clone commands compiler compilers concretize config containerize create deactivate debug dependencies dependents deprecate dev-build develop diff docs edit env extensions external fetch find gc gpg graph help info install license list load location log-parse maintainers make-installer mark mirror module patch pkg providers pydoc python reindex remove rm repo resource restage solve spec stage style tags test test-env tutorial undevelop uninstall unit-test unload url verify versions view"
fi fi
} }
@ -585,6 +585,15 @@ _spack_cd() {
fi fi
} }
_spack_change() {
if $list_options
then
SPACK_COMPREPLY="-h --help -l --list-name --match-spec -a --all"
else
_all_packages
fi
}
_spack_checksum() { _spack_checksum() {
if $list_options if $list_options
then then