diff --git a/lib/spack/spack/cmd/common/arguments.py b/lib/spack/spack/cmd/common/arguments.py index 5e3027babe2..e11a47162a6 100644 --- a/lib/spack/spack/cmd/common/arguments.py +++ b/lib/spack/spack/cmd/common/arguments.py @@ -529,6 +529,7 @@ def __call__(self, parser, namespace, values, option_string): # the const from the constructor or a value from the CLI. # Note that this is only called if the argument is actually # specified on the command line. + spack.config.CONFIG.ensure_scope_ordering() spack.config.set(self.config_path, self.const, scope="command_line") diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 9bf7675ac20..4252e18fc7b 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -431,6 +431,19 @@ def ensure_unwrapped(self) -> "Configuration": """Ensure we unwrap this object from any dynamic wrapper (like Singleton)""" return self + def highest(self) -> ConfigScope: + """Scope with highest precedence""" + return next(reversed(self.scopes.values())) # type: ignore + + @_config_mutator + def ensure_scope_ordering(self): + """Ensure that scope order matches documented precedent""" + # FIXME: We also need to consider that custom configurations and other orderings + # may not be preserved correctly + if "command_line" in self.scopes: + # TODO (when dropping python 3.6): self.scopes.move_to_end + self.scopes["command_line"] = self.remove_scope("command_line") + @_config_mutator def push_scope(self, scope: ConfigScope) -> None: """Add a higher precedence scope to the Configuration.""" diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 75d2e3d1f30..535afac96e2 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -3043,11 +3043,13 @@ def prepare_config_scope(self) -> None: """Add the manifest's scopes to the global configuration search path.""" for scope in self.env_config_scopes: spack.config.CONFIG.push_scope(scope) + spack.config.CONFIG.ensure_scope_ordering() def deactivate_config_scope(self) -> None: """Remove any of the manifest's scopes from the global config path.""" for scope in self.env_config_scopes: spack.config.CONFIG.remove_scope(scope.name) + spack.config.CONFIG.ensure_scope_ordering() @contextlib.contextmanager def use_config(self): diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index f0d4b0b7b0b..d8642df9bd5 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1441,3 +1441,30 @@ def test_config_path_dsl(path, it_should_work, expected_parsed): else: with pytest.raises(ValueError): spack.config.ConfigPath._validate(path) + + +@pytest.mark.regression("48254") +def test_env_activation_preserves_config_scopes(mutable_mock_env_path): + """Check that the "command_line" scope remains the highest priority scope, when we activate, + or deactivate, environments. + """ + expected_cl_scope = spack.config.CONFIG.highest() + assert expected_cl_scope.name == "command_line" + + # Creating an environment pushes a new scope + ev.create("test") + with ev.read("test"): + assert spack.config.CONFIG.highest() == expected_cl_scope + + # No active environment pops the scope + with ev.no_active_environment(): + assert spack.config.CONFIG.highest() == expected_cl_scope + assert spack.config.CONFIG.highest() == expected_cl_scope + + # Switch the environment to another one + ev.create("test-2") + with ev.read("test-2"): + assert spack.config.CONFIG.highest() == expected_cl_scope + assert spack.config.CONFIG.highest() == expected_cl_scope + + assert spack.config.CONFIG.highest() == expected_cl_scope