Repository: easybuilders/easybuild-framework

Issue:
make fix_python_shebang_for available for all easyblocks
Currently there is an option to use `fix_python_shebang_for` in `PythonPackage` easyblocks.
It would be useful to have this and  `fix_perl_shebang_for` available in other easyblocks too.



Discussion:

Resolving patch:
diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py
--- a/easybuild/framework/easyblock.py
+++ b/easybuild/framework/easyblock.py
@@ -72,12 +72,12 @@
 from easybuild.tools.config import install_path, log_path, package_path, source_paths
 from easybuild.tools.environment import restore_env, sanitize_env
 from easybuild.tools.filetools import CHECKSUM_TYPE_MD5, CHECKSUM_TYPE_SHA256
-from easybuild.tools.filetools import adjust_permissions, apply_patch, back_up_file, change_dir, convert_name
-from easybuild.tools.filetools import compute_checksum, copy_file, derive_alt_pypi_url, diff_files
-from easybuild.tools.filetools import download_file, encode_class_name, extract_file, find_backup_name_candidate
-from easybuild.tools.filetools import get_source_tarball_from_git, is_alt_pypi_url, is_sha256_checksum, mkdir
-from easybuild.tools.filetools import move_file, move_logs, read_file, remove_file, rmtree2, verify_checksum, weld_paths
-from easybuild.tools.filetools import write_file
+from easybuild.tools.filetools import adjust_permissions, apply_patch, apply_regex_substitutions, back_up_file
+from easybuild.tools.filetools import change_dir, convert_name, compute_checksum, copy_file, derive_alt_pypi_url
+from easybuild.tools.filetools import diff_files, download_file, encode_class_name, extract_file
+from easybuild.tools.filetools import find_backup_name_candidate, get_source_tarball_from_git, is_alt_pypi_url
+from easybuild.tools.filetools import is_sha256_checksum, mkdir, move_file, move_logs, read_file, remove_file, rmtree2
+from easybuild.tools.filetools import verify_checksum, weld_paths, write_file
 from easybuild.tools.hooks import BUILD_STEP, CLEANUP_STEP, CONFIGURE_STEP, EXTENSIONS_STEP, FETCH_STEP, INSTALL_STEP
 from easybuild.tools.hooks import MODULE_STEP, PACKAGE_STEP, PATCH_STEP, PERMISSIONS_STEP, POSTITER_STEP, POSTPROC_STEP
 from easybuild.tools.hooks import PREPARE_STEP, READY_STEP, SANITYCHECK_STEP, SOURCE_STEP, TEST_STEP, TESTCASES_STEP
@@ -2152,6 +2152,23 @@ def package_step(self):
         else:
             self.log.info("Skipping package step (not enabled)")
 
+    def fix_shebang(self):
+        """Fix shebang lines for specified files."""
+        for lang in ['perl', 'python']:
+            fix_shebang_for = self.cfg['fix_%s_shebang_for' % lang]
+            if fix_shebang_for:
+                if isinstance(fix_shebang_for, basestring):
+                    fix_shebang_for = [fix_shebang_for]
+
+                shebang = '#!/usr/bin/env %s' % lang
+                for glob_pattern in fix_shebang_for:
+                    paths = glob.glob(os.path.join(self.installdir, glob_pattern))
+                    self.log.info("Fixing '%s' shebang to '%s' for files that match '%s': %s",
+                                  lang, shebang, glob_pattern, paths)
+                    regex = r'^#!.*/%s[0-9.]*$' % lang
+                    for path in paths:
+                        apply_regex_substitutions(path, [(regex, shebang)], backup=False)
+
     def post_install_step(self):
         """
         Do some postprocessing
@@ -2167,6 +2184,8 @@ def post_install_step(self):
                     raise EasyBuildError("Invalid element in 'postinstallcmds', not a string: %s", cmd)
                 run_cmd(cmd, simple=True, log_ok=True, log_all=True)
 
+        self.fix_shebang()
+
     def sanity_check_step(self, *args, **kwargs):
         """
         Do a sanity check on the installation
diff --git a/easybuild/framework/easyconfig/default.py b/easybuild/framework/easyconfig/default.py
--- a/easybuild/framework/easyconfig/default.py
+++ b/easybuild/framework/easyconfig/default.py
@@ -91,6 +91,10 @@
     'easyblock': [None, "EasyBlock to use for building; if set to None, an easyblock is selected "
                         "based on the software name", BUILD],
     'easybuild_version': [None, "EasyBuild-version this spec-file was written for", BUILD],
+    'fix_perl_shebang_for': [None, "List of files for which Perl shebang should be fixed "
+                                   "to '#!/usr/bin/env perl' (glob patterns supported)", BUILD],
+    'fix_python_shebang_for': [None, "List of files for which Python shebang should be fixed "
+                                     "to '#!/usr/bin/env python' (glob patterns supported)", BUILD],
     'github_account': ['%(namelower)s', "GitHub account name to be used to resolve template values in source URLs",
                        BUILD],
     'hidden': [False, "Install module file as 'hidden' by prefixing its version with '.'", BUILD],


Test patch:
diff --git a/test/framework/toy_build.py b/test/framework/toy_build.py
--- a/test/framework/toy_build.py
+++ b/test/framework/toy_build.py
@@ -2199,6 +2199,47 @@ def check_toy_load(depends_on=False):
 
             check_toy_load(depends_on=True)
 
+    def test_fix_shebang(self):
+        """Test use of fix_python_shebang_for & co."""
+        test_ecs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'easyconfigs', 'test_ecs')
+        toy_ec_txt = read_file(os.path.join(test_ecs_dir, 't', 'toy', 'toy-0.0.eb'))
+
+        test_ec = os.path.join(self.test_prefix, 'test.eb')
+
+        test_ec_txt = '\n'.join([
+            toy_ec_txt,
+            "postinstallcmds = ["
+            "   'echo \"#!/usr/bin/python\\n# test\" > %(installdir)s/bin/t1.py',",
+            "   'echo \"#!/software/Python/3.6.6-foss-2018b/bin/python3.6\\n# test\" > %(installdir)s/bin/t2.py',",
+            "   'echo \"#!/usr/bin/env python\\n# test\" > %(installdir)s/bin/t3.py',",
+            "   'echo \"#!/usr/bin/perl\\n# test\" > %(installdir)s/bin/t1.pl',",
+            "   'echo \"#!/software/Perl/5.28.1-GCCcore-7.3.0/bin/perl5\\n# test\" > %(installdir)s/bin/t2.pl',",
+            "   'echo \"#!/usr/bin/env perl\\n# test\" > %(installdir)s/bin/t3.pl',",
+            "]",
+            "fix_python_shebang_for = ['bin/t1.py', 'bin/*.py', 'nosuchdir/*.py']",
+            "fix_perl_shebang_for = 'bin/*.pl'",
+        ])
+        write_file(test_ec, test_ec_txt)
+        self.test_toy_build(ec_file=test_ec, raise_error=True)
+
+        toy_bindir = os.path.join(self.test_installpath, 'software', 'toy', '0.0', 'bin')
+
+        # no re.M, this should match at start of file!
+        py_shebang_regex = re.compile(r'^#!/usr/bin/env python\n# test$')
+        for pybin in ['t1.py', 't2.py', 't3.py']:
+            pybin_path = os.path.join(toy_bindir, pybin)
+            pybin_txt = read_file(pybin_path)
+            self.assertTrue(py_shebang_regex.match(pybin_txt),
+                            "Pattern '%s' found in %s: %s" % (py_shebang_regex.pattern, pybin_path, pybin_txt))
+
+        # no re.M, this should match at start of file!
+        perl_shebang_regex = re.compile(r'^#!/usr/bin/env perl\n# test$')
+        for perlbin in ['t1.pl', 't2.pl', 't3.pl']:
+            perlbin_path = os.path.join(toy_bindir, perlbin)
+            perlbin_txt = read_file(perlbin_path)
+            self.assertTrue(perl_shebang_regex.match(perlbin_txt),
+                            "Pattern '%s' found in %s: %s" % (perl_shebang_regex.pattern, perlbin_path, perlbin_txt))
+
 
 def suite():
     """ return all the tests in this file """