Merge branch 'bschubert/resolve-variables' into 'master'

Always resolve variables in elements

See merge request BuildStream/buildstream!1919
diff --git a/NEWS b/NEWS
index f7e79b1..b69e3c9 100644
--- a/NEWS
+++ b/NEWS
@@ -22,6 +22,14 @@
   o The `pip` element has been removed. Please use the one from bst-plugins-experimental
 
 
+API
+---
+
+  o `Element.node_subst_vars` and `Element.node_subst_sequence_vars` are now deprecated
+     and will get removed in the next version. All config entries are now resolved so there
+     is no need to use them anymore.
+
+
 ==================
 buildstream 1.93.3
 ==================
diff --git a/src/buildstream/_options/option.py b/src/buildstream/_options/option.py
index 71d2f12..5c0eb50 100644
--- a/src/buildstream/_options/option.py
+++ b/src/buildstream/_options/option.py
@@ -76,9 +76,8 @@
     # Args:
     #    node (Mapping): The YAML loaded key/value dictionary
     #                    to load the value from
-    #    transform (callbable): Transform function for variable substitution
     #
-    def load_value(self, node, *, transform=None):
+    def load_value(self, node):
         pass  # pragma: nocover
 
     # set_value()
diff --git a/src/buildstream/_options/optionbool.py b/src/buildstream/_options/optionbool.py
index f615982..57dbd16 100644
--- a/src/buildstream/_options/optionbool.py
+++ b/src/buildstream/_options/optionbool.py
@@ -36,11 +36,8 @@
         node.validate_keys(OPTION_SYMBOLS + ["default"])
         self.value = node.get_bool("default")
 
-    def load_value(self, node, *, transform=None):
-        if transform:
-            self.set_value(transform(node.get_scalar(self.name)))
-        else:
-            self.value = node.get_bool(self.name)
+    def load_value(self, node):
+        self.value = node.get_bool(self.name)
 
     def set_value(self, value):
         if value in ("True", "true"):
diff --git a/src/buildstream/_options/optionenum.py b/src/buildstream/_options/optionenum.py
index 12ec7cb..da3a7dc 100644
--- a/src/buildstream/_options/optionenum.py
+++ b/src/buildstream/_options/optionenum.py
@@ -58,12 +58,9 @@
         # Allow subclass to define the default value
         self.value = self.load_default_value(node)
 
-    def load_value(self, node, *, transform=None):
+    def load_value(self, node):
         value_node = node.get_scalar(self.name)
-        if transform:
-            self.value = transform(value_node)
-        else:
-            self.value = value_node.as_str()
+        self.value = value_node.as_str()
 
         self.validate(self.value, value_node)
 
diff --git a/src/buildstream/_options/optionflags.py b/src/buildstream/_options/optionflags.py
index b19a227..042ef64 100644
--- a/src/buildstream/_options/optionflags.py
+++ b/src/buildstream/_options/optionflags.py
@@ -60,13 +60,9 @@
         self.value = value_node.as_str_list()
         self.validate(self.value, value_node)
 
-    def load_value(self, node, *, transform=None):
+    def load_value(self, node):
         value_node = node.get_sequence(self.name)
-        if transform:
-            self.value = [transform(x) for x in value_node]
-        else:
-            self.value = value_node.as_str_list()
-        self.value = sorted(self.value)
+        self.value = sorted(value_node.as_str_list())
         self.validate(self.value, value_node)
 
     def set_value(self, value):
diff --git a/src/buildstream/_options/optionpool.py b/src/buildstream/_options/optionpool.py
index dee038f..c05c559 100644
--- a/src/buildstream/_options/optionpool.py
+++ b/src/buildstream/_options/optionpool.py
@@ -104,7 +104,7 @@
     # Args:
     #    node (dict): The loaded YAML options
     #
-    def load_yaml_values(self, node, *, transform=None):
+    def load_yaml_values(self, node):
         for option_name, option_value in node.items():
             try:
                 option = self._options[option_name]
@@ -113,7 +113,7 @@
                 raise LoadError(
                     "{}: Unknown option '{}' specified".format(p, option_name), LoadErrorReason.INVALID_DATA
                 ) from e
-            option.load_value(node, transform=transform)
+            option.load_value(node)
 
     # load_cli_values()
     #
diff --git a/src/buildstream/_project.py b/src/buildstream/_project.py
index 45f3b4d..06c6770 100644
--- a/src/buildstream/_project.py
+++ b/src/buildstream/_project.py
@@ -836,7 +836,7 @@
         output.options.load(options_node)
         if self.junction:
             # load before user configuration
-            output.options.load_yaml_values(self.junction.options, transform=self.junction.node_subst_vars)
+            output.options.load_yaml_values(self.junction.options)
 
         # Collect option values specified in the user configuration
         overrides = self._context.get_overrides(self.name)
diff --git a/src/buildstream/_variables.pyx b/src/buildstream/_variables.pyx
index f9de2a6..9dc6cf6 100644
--- a/src/buildstream/_variables.pyx
+++ b/src/buildstream/_variables.pyx
@@ -25,7 +25,7 @@
 
 from ._exceptions import LoadError
 from .exceptions import LoadErrorReason
-from .node cimport MappingNode
+from .node cimport MappingNode, Node, ScalarNode, SequenceNode
 
 # Variables are allowed to have dashes here
 #
@@ -75,6 +75,27 @@
         self._expstr_map = self._resolve(node)
         self.flat = self._flatten()
 
+    # expand()
+    #
+    # Expand all the variables found in the given Node, recursively.
+    # This does the change in place, modifying the node. If you want to keep
+    # the node untouched, you should use `node.clone()` beforehand
+    #
+    # Args:
+    #   (Node): A node for which to substitute the values
+    #
+    cpdef expand(self, Node node):
+        if isinstance(node, ScalarNode):
+            (<ScalarNode> node).value = self.subst((<ScalarNode> node).value)
+        elif isinstance(node, SequenceNode):
+            for entry in (<SequenceNode> node).value:
+                self.expand(entry)
+        elif isinstance(node, MappingNode):
+            for entry in (<MappingNode> node).value.values():
+                self.expand(entry)
+        else:
+            assert False, "Unknown 'Node' type"
+
     # subst():
     #
     # Substitutes any variables in 'string' and returns the result.
@@ -88,7 +109,7 @@
     # Raises:
     #    LoadError, if the string contains unresolved variable references.
     #
-    def subst(self, str string):
+    cpdef subst(self, str string):
         expstr = _parse_expstr(string)
 
         try:
diff --git a/src/buildstream/buildelement.py b/src/buildstream/buildelement.py
index 32b64a8..ce6bb33 100644
--- a/src/buildstream/buildelement.py
+++ b/src/buildstream/buildelement.py
@@ -172,10 +172,7 @@
         node.validate_keys(_command_steps)
 
         for command_name in _legacy_command_steps:
-            if command_name in _command_steps:
-                self.__commands[command_name] = self.__get_commands(node, command_name)
-            else:
-                self.__commands[command_name] = []
+            self.__commands[command_name] = node.get_str_list(command_name, [])
 
     def preflight(self):
         pass
@@ -305,10 +302,6 @@
     #############################################################
     #                   Private Local Methods                   #
     #############################################################
-    def __get_commands(self, node, name):
-        raw_commands = node.get_sequence(name, [])
-        return [self.node_subst_vars(command) for command in raw_commands]
-
     def __run_command(self, sandbox, cmd):
         # Note the -e switch to 'sh' means to exit with an error
         # if any untested command fails.
diff --git a/src/buildstream/element.py b/src/buildstream/element.py
index d8c0f6f..67a915e 100644
--- a/src/buildstream/element.py
+++ b/src/buildstream/element.py
@@ -76,6 +76,7 @@
 import re
 import stat
 import copy
+import warnings
 from collections import OrderedDict
 import contextlib
 from contextlib import contextmanager
@@ -286,7 +287,8 @@
 
         # Collect the composited environment now that we have variables
         unexpanded_env = self.__extract_environment(project, meta)
-        self.__environment = self.__expand_environment(unexpanded_env)
+        self.__variables.expand(unexpanded_env)
+        self.__environment = unexpanded_env.strip_node_info()
 
         # Collect the environment nocache blacklist list
         nocache = self.__extract_env_nocache(project, meta)
@@ -300,6 +302,8 @@
         # Collect the composited element configuration and
         # ask the element to configure itself.
         self.__config = self.__extract_config(meta)
+        self.__variables.expand(self.__config)
+
         self._configure(self.__config)
 
         # Extract remote execution URL
@@ -502,6 +506,8 @@
     def node_subst_vars(self, node: "ScalarNode") -> str:
         """Replace any variables in the string contained in the node and returns it.
 
+        **Warning**: The method is deprecated and will get removed in the next version
+
         Args:
            node: A ScalarNode loaded from YAML
 
@@ -519,15 +525,17 @@
           # variables in the returned string
           name = self.node_subst_vars(node.get_scalar('name'))
         """
-        try:
-            return self.__variables.subst(node.as_str())
-        except LoadError as e:
-            provenance = node.get_provenance()
-            raise LoadError("{}: {}".format(provenance, e), e.reason, detail=e.detail) from e
+        # FIXME: remove this
+        warnings.warn(
+            "configuration is now automatically expanded, this is a no-op and will be removed.", DeprecationWarning
+        )
+        return node.as_str()
 
     def node_subst_sequence_vars(self, node: "SequenceNode[ScalarNode]") -> List[str]:
         """Substitute any variables in the given sequence
 
+        **Warning**: The method is deprecated and will get removed in the next version
+
         Args:
           node: A SequenceNode loaded from YAML
 
@@ -538,14 +546,11 @@
           :class:`.LoadError`
 
         """
-        ret = []
-        for value in node:
-            try:
-                ret.append(self.__variables.subst(value.as_str()))
-            except LoadError as e:
-                provenance = value.get_provenance()
-                raise LoadError("{}: {}".format(provenance, e), e.reason, detail=e.detail) from e
-        return ret
+        # FIXME: remove this
+        warnings.warn(
+            "configuration is now automatically expanded, this is a no-op and will be removed.", DeprecationWarning
+        )
+        return node.as_str_list()
 
     def compute_manifest(
         self, *, include: Optional[List[str]] = None, exclude: Optional[List[str]] = None, orphans: bool = True
@@ -760,11 +765,8 @@
 
         if bstdata is not None:
             with sandbox.batch(SandboxFlags.NONE):
-                commands = bstdata.get_sequence("integration-commands", [])
-                for command in commands:
-                    cmd = self.node_subst_vars(command)
-
-                    sandbox.run(["sh", "-e", "-c", cmd], 0, env=environment, cwd="/", label=cmd)
+                for command in bstdata.get_str_list("integration-commands", []):
+                    sandbox.run(["sh", "-e", "-c", command], 0, env=environment, cwd="/", label=command)
 
     def stage_sources(self, sandbox: "Sandbox", directory: str) -> None:
         """Stage this element's sources to a directory in the sandbox
@@ -2566,17 +2568,6 @@
 
         return environment
 
-    # This will resolve the final environment to be used when
-    # creating sandboxes for this element
-    #
-    def __expand_environment(self, environment):
-        # Resolve variables in environment value strings
-        final_env = {}
-        for key, value in environment.items():
-            final_env[key] = self.node_subst_vars(value)
-
-        return final_env
-
     @classmethod
     def __extract_env_nocache(cls, project, meta):
         if meta.is_junction:
diff --git a/src/buildstream/plugin.py b/src/buildstream/plugin.py
index dfb5433..6795043 100644
--- a/src/buildstream/plugin.py
+++ b/src/buildstream/plugin.py
@@ -301,11 +301,6 @@
         should be used to ensure that the user has not specified keys in `node` which are unsupported
         by the plugin.
 
-        .. note::
-
-           For Elements, when variable substitution is desirable, the
-           :func:`Element.node_subst_vars() <buildstream.element.Element.node_subst_vars>`
-           method can be used.
         """
         raise ImplError(
             "{tag} plugin '{kind}' does not implement configure()".format(tag=self.__type_tag, kind=self.get_kind())
diff --git a/src/buildstream/plugins/elements/import.py b/src/buildstream/plugins/elements/import.py
index d9961aa..de7ee8a 100644
--- a/src/buildstream/plugins/elements/import.py
+++ b/src/buildstream/plugins/elements/import.py
@@ -46,8 +46,8 @@
     def configure(self, node):
         node.validate_keys(["source", "target"])
 
-        self.source = self.node_subst_vars(node.get_scalar("source"))
-        self.target = self.node_subst_vars(node.get_scalar("target"))
+        self.source = node.get_str("source")
+        self.target = node.get_str("target")
 
     def preflight(self):
         # Assert that we have at least one source to fetch.
diff --git a/src/buildstream/plugins/elements/script.py b/src/buildstream/plugins/elements/script.py
index 9d780eb..502212e 100644
--- a/src/buildstream/plugins/elements/script.py
+++ b/src/buildstream/plugins/elements/script.py
@@ -46,14 +46,13 @@
 
     def configure(self, node):
         for n in node.get_sequence("layout", []):
-            dst = self.node_subst_vars(n.get_scalar("destination"))
-            elm = self.node_subst_vars(n.get_scalar("element", None))
+            dst = n.get_str("destination")
+            elm = n.get_str("element", None)
             self.layout_add(elm, dst)
 
         node.validate_keys(["commands", "root-read-only", "layout"])
 
-        cmds = self.node_subst_sequence_vars(node.get_sequence("commands"))
-        self.add_commands("commands", cmds)
+        self.add_commands("commands", node.get_str_list("commands"))
 
         self.set_work_dir()
         self.set_install_root()