Repository: tianocore/edk2-pytool-extensions

Issue:
[Feature]: Allow build wrappers
### What does the feature solve?

Stuart ultimately calls the edk2 `build` command to build code.

It would be useful to allow plugins or other Python code leveraging stuart to be able to reuse stuart logic but substitute that actual build command with a build wrapper. Wrappers can be used to trace build commands and perform special build setup that does not need to be officially support in the stuart build logic.

### Describe the solution

Allow the build command and build parameters used by stuart to be specified dynamically.

Build variables would be a simple solution.

### Have you considered any alternatives?

Yes, PR #324.

However, most of that work can be done in a plugin so it does not bloat the main stuart logic. The minimum piece needed for the plugin to work is the change in this feature request.

### Additional context

Used to wrap the CodeQL CLI around the build command.

Comments:

Patch:
diff --git a/docs/usability/environment_variables.md b/docs/usability/environment_variables.md
index 5dab021..52611e5 100644
--- a/docs/usability/environment_variables.md
+++ b/docs/usability/environment_variables.md
@@ -28,3 +28,13 @@ calling `stuart_update` and make it far easier to switch between multiple branch
 scopes where dependencies may change.
 
 For more info, see [the External Dependencies docs](using_extdep.md).
+
+## EDK_BUILD_CMD
+
+If present, the absolute path to an application to use for the edk build process will be
+invoked instead of `build`. This is primarily used to allow a tool to wrap around `build`.
+
+## EDK_BUILD_PARAMS
+
+If present, these parameters will be passed to the build command. This is primarily used to
+pair wrapper-specific parameters with the wrapper passed in `EDK_BUILD_CMD`.
diff --git a/edk2toolext/environment/uefi_build.py b/edk2toolext/environment/uefi_build.py
index 0863b12..575bcab 100644
--- a/edk2toolext/environment/uefi_build.py
+++ b/edk2toolext/environment/uefi_build.py
@@ -310,7 +310,18 @@ def Build(self):
         pre_build_env_chk = env.checkpoint()
         env.set_shell_var('PYTHONHASHSEED', '0')
         env.log_environment()
-        ret = RunCmd("build", params)
+
+        edk2_build_cmd = self.env.GetValue("EDK_BUILD_CMD")
+        if edk2_build_cmd is None:
+            edk2_build_cmd = "build"
+        logging.debug(f"The edk2 build command is {edk2_build_cmd}")
+
+        edk2_build_params = self.env.GetValue("EDK_BUILD_PARAMS")
+        if edk2_build_params is None:
+            edk2_build_params = params
+        logging.debug(f"Edk2 build parameters are {edk2_build_params}")
+
+        ret = RunCmd(edk2_build_cmd, edk2_build_params)
         # WORKAROUND - Undo the workaround.
         env.restore_checkpoint(pre_build_env_chk)
 


Test patch:
diff --git a/edk2toolext/tests/test_uefi_build.py b/edk2toolext/tests/test_uefi_build.py
index 550bd15..19cf44d 100644
--- a/edk2toolext/tests/test_uefi_build.py
+++ b/edk2toolext/tests/test_uefi_build.py
@@ -10,9 +10,12 @@
 from edk2toolext.environment import uefi_build
 from edk2toolext.environment.plugintypes import uefi_helper_plugin
 from edk2toolext.environment.plugin_manager import PluginManager
+from edk2toollib.utility_functions import GetHostInfo
 import argparse
 import tempfile
 import os
+import stat
+from inspect import cleandoc
 from edk2toolext.environment import shell_environment
 
 
@@ -91,6 +94,72 @@ def test_go_skip_building(self):
         ret = builder.Go(self.WORKSPACE, "", helper, manager)
         self.assertEqual(ret, 0)
 
+    def test_build_wrapper(self):
+        """Tests that a build wrapper can be used."""
+        builder = uefi_build.UefiBuilder()
+
+        # Post-build is not needed to test the build wrapper
+        builder.SkipPostBuild = True
+
+        # Some basic build variables need to be set to make it through
+        # the build preamble to the point the wrapper gets called.
+        shell_environment.GetBuildVars().SetValue("TARGET_ARCH",
+                                                  "IA32",
+                                                  "Set in build wrapper test")
+        shell_environment.GetBuildVars().SetValue("EDK_TOOLS_PATH",
+                                                  self.WORKSPACE,
+                                                  "Set in build wrapper test")
+
+        # "build_wrapper" -> The actual build_wrapper script
+        # "test_file" -> An empty file written by build_wrapper
+        build_wrapper_path = os.path.join(self.WORKSPACE, "build_wrapper")
+        test_file_path = os.path.join(self.WORKSPACE, "test_file")
+
+        # This script will write an empty file called "test_file" to the
+        # temporary directory (workspace) to demonstrate that it ran successfully
+        build_wrapper_file_content = """
+            import os
+            import sys
+
+            test_file_dir = os.path.dirname(os.path.realpath(__file__))
+            test_file_path = os.path.join(test_file_dir, "test_file")
+
+            with open(test_file_path, 'w'):
+                pass
+
+            sys.exit(0)
+            """
+
+        build_wrapper_cmd = "python"
+        build_wrapper_params = os.path.normpath(build_wrapper_path)
+
+        TestUefiBuild.write_to_file(
+            build_wrapper_path,
+            cleandoc(build_wrapper_file_content))
+
+        if GetHostInfo().os == "Linux":
+            os.chmod(build_wrapper_path,
+                     os.stat(build_wrapper_path).st_mode | stat.S_IEXEC)
+
+        # This is the main point of this test. The wrapper file should be
+        # executed instead of the build command. In real scenarios, the wrapper
+        # script would subsequently call the build command.
+        shell_environment.GetBuildVars().SetValue(
+            "EDK_BUILD_CMD", build_wrapper_cmd, "Set in build wrapper test")
+        shell_environment.GetBuildVars().SetValue(
+            "EDK_BUILD_PARAMS", build_wrapper_params, "Set in build wrapper test")
+
+        manager = PluginManager()
+        helper = uefi_helper_plugin.HelperFunctions()
+        ret = builder.Go(self.WORKSPACE, "", helper, manager)
+
+        # Check the build wrapper return code
+        self.assertEqual(ret, 0)
+
+        # Check that the build wrapper ran successfully by checking that the
+        # file written by the build wrapper file exists
+        self.assertTrue(os.path.isfile(test_file_path))
+
     # TODO finish unit test