From 7b27591321fca14c519e75a3ab76a7e231347594 Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Thu, 18 Jan 2024 00:21:17 -0800 Subject: New command: `spack config change` (#41147) Like `spack change` for specs in environments, this can e.g. replace `examplespec+debug` with `examplespec~debug` in a `require:` section. Example behavior for a config like: ``` packages: foo: require: - spec: +debug ``` * `spack config change packages:foo:require:~debug` replaces `+debug` with `~debug` * `spack config change packages:foo:require:@1.1` adds a requirement to the list * `spack config change packages:bar:require:~debug` adds a requirement --- lib/spack/spack/cmd/config.py | 93 +++++++++++++++++++++++++++++++++++++++ lib/spack/spack/config.py | 3 +- lib/spack/spack/test/cmd/env.py | 97 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) (limited to 'lib') diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index 50987ea069..adfe286c37 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -76,6 +76,10 @@ def setup_parser(subparser): ) add_parser.add_argument("-f", "--file", help="file from which to set all config values") + change_parser = sp.add_parser("change", help="swap variants etc. on specs in config") + change_parser.add_argument("path", help="colon-separated path to config section with specs") + change_parser.add_argument("--match-spec", help="only change constraints that match this") + prefer_upstream_parser = sp.add_parser( "prefer-upstream", help="set package preferences from upstream" ) @@ -263,6 +267,94 @@ def _can_update_config_file(scope: spack.config.ConfigScope, cfg_file): return fs.can_write_to_dir(scope.path) and fs.can_access(cfg_file) +def _config_change_requires_scope(path, spec, scope, match_spec=None): + """Return whether or not anything changed.""" + require = spack.config.get(path, scope=scope) + if not require: + return False + + changed = False + + def override_cfg_spec(spec_str): + nonlocal changed + + init_spec = spack.spec.Spec(spec_str) + # Overridden spec cannot be anonymous + init_spec.name = spec.name + if match_spec and not init_spec.satisfies(match_spec): + # If there is a match_spec, don't change constraints that + # don't match it + return spec_str + elif not init_spec.intersects(spec): + changed = True + return str(spack.spec.Spec.override(init_spec, spec)) + else: + # Don't override things if they intersect, otherwise we'd + # be e.g. attaching +debug to every single version spec + return spec_str + + if isinstance(require, str): + new_require = override_cfg_spec(require) + else: + new_require = [] + for item in require: + if "one_of" in item: + item["one_of"] = [override_cfg_spec(x) for x in item["one_of"]] + elif "any_of" in item: + item["any_of"] = [override_cfg_spec(x) for x in item["any_of"]] + elif "spec" in item: + item["spec"] = override_cfg_spec(item["spec"]) + new_require.append(item) + + spack.config.set(path, new_require, scope=scope) + return changed + + +def _config_change(config_path, match_spec_str=None): + all_components = spack.config.process_config_path(config_path) + key_components = all_components[:-1] + key_path = ":".join(key_components) + + spec = spack.spec.Spec(syaml.syaml_str(all_components[-1])) + + match_spec = None + if match_spec_str: + match_spec = spack.spec.Spec(match_spec_str) + + if key_components[-1] == "require": + # Extract the package name from the config path, which allows + # args.spec to be anonymous if desired + pkg_name = key_components[1] + spec.name = pkg_name + + changed = False + for scope in spack.config.writable_scope_names(): + changed |= _config_change_requires_scope(key_path, spec, scope, match_spec=match_spec) + + if not changed: + existing_requirements = spack.config.get(key_path) + if isinstance(existing_requirements, str): + raise spack.config.ConfigError( + "'config change' needs to append a requirement," + " but existing require: config is not a list" + ) + + ideal_scope_to_modify = None + for scope in spack.config.writable_scope_names(): + if spack.config.get(key_path, scope=scope): + ideal_scope_to_modify = scope + break + + update_path = f"{key_path}:[{str(spec)}]" + spack.config.add(update_path, scope=ideal_scope_to_modify) + else: + raise ValueError("'config change' can currently only change 'require' sections") + + +def config_change(args): + _config_change(args.path, args.match_spec) + + def config_update(args): # Read the configuration files spack.config.CONFIG.get_config(args.section, scope=args.scope) @@ -490,5 +582,6 @@ def config(parser, args): "update": config_update, "revert": config_revert, "prefer-upstream": config_prefer_upstream, + "change": config_change, } action[args.config_command](args) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index d13cc643a2..be6aeea7f0 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -950,7 +950,8 @@ def scopes() -> Dict[str, ConfigScope]: def writable_scopes() -> List[ConfigScope]: """ - Return list of writable scopes + Return list of writable scopes. Higher-priority scopes come first in the + list. """ return list( reversed( diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index aaf2b83e23..0908ae3be3 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -48,6 +48,7 @@ env = SpackCommand("env") install = SpackCommand("install") add = SpackCommand("add") change = SpackCommand("change") +config = SpackCommand("config") remove = SpackCommand("remove") concretize = SpackCommand("concretize") stage = SpackCommand("stage") @@ -869,6 +870,102 @@ spack: assert any(x.satisfies("mpileaks@2.2") for x in e._get_environment_specs()) +def test_config_change_existing(mutable_mock_env_path, tmp_path, mock_packages, mutable_config): + """Test ``config change`` with config in the ``spack.yaml`` as well as an + included file scope. + """ + + included_file = "included-packages.yaml" + included_path = tmp_path / included_file + with open(included_path, "w") as f: + f.write( + """\ +packages: + mpich: + require: + - spec: "@3.0.2" + libelf: + require: "@0.8.10" + bowtie: + require: + - one_of: ["@1.3.0", "@1.2.0"] +""" + ) + + spack_yaml = tmp_path / ev.manifest_name + spack_yaml.write_text( + f"""\ +spack: + packages: + mpich: + require: + - spec: "+debug" + include: + - {os.path.join(".", included_file)} + specs: [] +""" + ) + + e = ev.Environment(tmp_path) + with e: + # List of requirements, flip a variant + config("change", "packages:mpich:require:~debug") + test_spec = spack.spec.Spec("mpich").concretized() + assert test_spec.satisfies("@3.0.2~debug") + + # List of requirements, change the version (in a different scope) + config("change", "packages:mpich:require:@3.0.3") + test_spec = spack.spec.Spec("mpich").concretized() + assert test_spec.satisfies("@3.0.3") + + # "require:" as a single string, also try specifying + # a spec string that requires enclosing in quotes as + # part of the config path + config("change", 'packages:libelf:require:"@0.8.12:"') + test_spec = spack.spec.Spec("libelf@0.8.12").concretized() + # No need for assert, if there wasn't a failure, we + # changed the requirement successfully. + + # Use "--match-spec" to change one spec in a "one_of" + # list + config("change", "packages:bowtie:require:@1.2.2", "--match-spec", "@1.2.0") + spack.spec.Spec("bowtie@1.3.0").concretize() + spack.spec.Spec("bowtie@1.2.2").concretized() + + +def test_config_change_new(mutable_mock_env_path, tmp_path, mock_packages, mutable_config): + spack_yaml = tmp_path / ev.manifest_name + spack_yaml.write_text( + """\ +spack: + specs: [] +""" + ) + + e = ev.Environment(tmp_path) + with e: + config("change", "packages:mpich:require:~debug") + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + spack.spec.Spec("mpich+debug").concretized() + spack.spec.Spec("mpich~debug").concretized() + + # Now check that we raise an error if we need to add a require: constraint + # when preexisting config manually specified it as a singular spec + spack_yaml.write_text( + """\ +spack: + specs: [] + packages: + mpich: + require: "@3.0.3" +""" + ) + with e: + assert spack.spec.Spec("mpich").concretized().satisfies("@3.0.3") + with pytest.raises(spack.config.ConfigError, match="not a list"): + config("change", "packages:mpich:require:~debug") + + def test_env_with_included_config_file_url(tmpdir, mutable_empty_config, packages_file): """Test configuration inclusion of a file whose path is a URL before the environment is concretized.""" -- cgit v1.2.3-70-g09d2