From a468ebb93b98f22b992cbfe5150dafbe631eae44 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Thu, 12 Feb 2026 13:38:56 +0100 Subject: [PATCH 1/6] Make test_cases_step command executable Scripts specified in the `tests` easyconfig parameter, especially from PRs, might not be executable. Temporarily make them executable to avoid failing with permission issues. --- easybuild/framework/easyblock.py | 24 +++++++++++--- test/framework/easyblock.py | 56 ++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py index d72ab4e9d3..57ea075f37 100644 --- a/easybuild/framework/easyblock.py +++ b/easybuild/framework/easyblock.py @@ -4668,13 +4668,27 @@ def test_cases_step(self): test_cmd = os.path.join(source_path, self.name, test) if os.path.exists(test_cmd): break - if not os.path.exists(test_cmd): - raise EasyBuildError(f"Test specifies invalid path: {test_cmd}") - + else: + test_cmd = test + if not os.path.exists(test_cmd): + raise EasyBuildError(f"Test specifies invalid path: {test_cmd}") + + if os.path.isfile(test_cmd): + original_perms = os.lstat(test_cmd).st_mode + if original_perms & stat.S_IEXEC: + original_perms = None + else: + adjust_permissions(test_cmd, stat.S_IEXEC, add=True, recursive=False) + else: + original_perms = None try: self.log.debug(f"Running test {test_cmd}") - run_shell_cmd(test_cmd) - except EasyBuildError as err: + try: + run_shell_cmd(test_cmd) + finally: + if original_perms is not None: + adjust_permissions(test_cmd, original_perms, relative=False) + except BaseException as err: raise EasyBuildError(f"Running test {test_cmd} failed: {err}") def update_config_template_run_step(self): diff --git a/test/framework/easyblock.py b/test/framework/easyblock.py index 601b21b7f8..71b965cbb8 100644 --- a/test/framework/easyblock.py +++ b/test/framework/easyblock.py @@ -34,6 +34,7 @@ import os import re import shutil +import stat import sys import tempfile import textwrap @@ -52,8 +53,8 @@ from easybuild.tools import LooseVersion, config from easybuild.tools.build_log import EasyBuildError from easybuild.tools.config import get_module_syntax, update_build_option -from easybuild.tools.filetools import change_dir, copy_dir, copy_file, mkdir, read_file, remove_dir, remove_file -from easybuild.tools.filetools import symlink, verify_checksum, write_file +from easybuild.tools.filetools import adjust_permissions, change_dir, copy_dir, copy_file, mkdir, read_file +from easybuild.tools.filetools import remove_dir, remove_file, symlink, verify_checksum, write_file from easybuild.tools.module_generator import module_generator from easybuild.tools.modules import EnvironmentModules, Lmod, reset_module_caches from easybuild.tools.version import get_git_revision, this_is_easybuild @@ -1271,6 +1272,57 @@ def test_handle_iterate_opts(self): self.assertEqual(eb.cfg.iterating, False) self.assertEqual(eb.cfg['configopts'], ["--opt1 --anotheropt", "--opt2", "--opt3 --optbis"]) + def test_test_cases_step(self): + """Test test_cases_step""" + self.contents = '\n'.join([ + 'easyblock = "ConfigureMake"', + 'name = "pi"', + 'version = "3.14"', + 'homepage = "http://example.com"', + 'description = "test easyconfig"', + "toolchain = {'name': 'gompi', 'version': '2018a'}", + ]) + self.writeEC() + eb = EasyBlock(EasyConfig(self.eb_file)) + eb.cfg['tests'] = ['does-not-exist'] + self.assertRaisesRegex(EasyBuildError, 'invalid path: does-not-exist', eb.test_cases_step) + eb.cfg['tests'] = ['/abs/path/does-not-exist'] + self.assertRaisesRegex(EasyBuildError, 'invalid path: /abs/path/does-not-exist', eb.test_cases_step) + + mock_test_bin = os.path.join(self.test_prefix, 'pi', 'test_me') + os.environ['PATH'] += f':{os.path.dirname(mock_test_bin)}' + write_file(mock_test_bin, "#!/bin/bash\necho 'Test case success'") + + adjust_permissions(mock_test_bin, stat.S_IXUSR) + eb.cfg['tests'] = [os.path.basename(mock_test_bin)] + self.assertRaisesRegex(EasyBuildError, f'invalid path: {os.path.basename(mock_test_bin)}', eb.test_cases_step) + + init_config(args=[f"--sourcepath={self.test_prefix}"]) + eb.test_cases_step() + self.assertIn('Test case success', read_file(eb.logfile)) + + # Also works with non-executable file + write_file(eb.logfile, '') + perms = stat.S_IREAD + adjust_permissions(mock_test_bin, perms, relative=False) + eb.cfg['tests'] = [os.path.basename(mock_test_bin)] + eb.test_cases_step() + self.assertEqual(stat.S_IMODE(os.lstat(mock_test_bin).st_mode), perms) # Permissions unchanged + self.assertIn('Test case success', read_file(eb.logfile)) + + # Similar for absolute paths + write_file(eb.logfile, '') + eb.cfg['tests'] = [mock_test_bin] + eb.test_cases_step() + self.assertIn('Test case success', read_file(eb.logfile)) + + # Detect failure + adjust_permissions(mock_test_bin, stat.S_IWRITE) + write_file(mock_test_bin, "#!/bin/bash\necho 'Test case failure' && exit 1") + write_file(eb.logfile, '') + self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin} failed', eb.test_cases_step) + self.assertIn('Test case failure', read_file(eb.logfile)) + def test_post_processing_step(self): """Test post_processing_step and deprecated post_install_step.""" init_config(build_options={'silent': True}) From 392177dac31b393e7bc39301e0363ad8f2ba5be5 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Thu, 12 Feb 2026 13:43:33 +0100 Subject: [PATCH 2/6] Check multiple test cases --- test/framework/easyblock.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/framework/easyblock.py b/test/framework/easyblock.py index 71b965cbb8..0daeaa5ed3 100644 --- a/test/framework/easyblock.py +++ b/test/framework/easyblock.py @@ -1298,31 +1298,42 @@ def test_test_cases_step(self): self.assertRaisesRegex(EasyBuildError, f'invalid path: {os.path.basename(mock_test_bin)}', eb.test_cases_step) init_config(args=[f"--sourcepath={self.test_prefix}"]) + write_file(eb.logfile, '') eb.test_cases_step() self.assertIn('Test case success', read_file(eb.logfile)) # Also works with non-executable file - write_file(eb.logfile, '') perms = stat.S_IREAD adjust_permissions(mock_test_bin, perms, relative=False) eb.cfg['tests'] = [os.path.basename(mock_test_bin)] + write_file(eb.logfile, '') eb.test_cases_step() self.assertEqual(stat.S_IMODE(os.lstat(mock_test_bin).st_mode), perms) # Permissions unchanged self.assertIn('Test case success', read_file(eb.logfile)) # Similar for absolute paths - write_file(eb.logfile, '') eb.cfg['tests'] = [mock_test_bin] + write_file(eb.logfile, '') eb.test_cases_step() self.assertIn('Test case success', read_file(eb.logfile)) # Detect failure adjust_permissions(mock_test_bin, stat.S_IWRITE) - write_file(mock_test_bin, "#!/bin/bash\necho 'Test case failure' && exit 1") + mock_test_bin_fail = mock_test_bin + "_fail" + write_file(mock_test_bin_fail, "#!/bin/bash\necho 'Test case failure' && exit 1") + eb.cfg['tests'] = [mock_test_bin_fail] write_file(eb.logfile, '') - self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin} failed', eb.test_cases_step) + self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin_fail} failed', eb.test_cases_step) self.assertIn('Test case failure', read_file(eb.logfile)) + # Multiple tests + eb.cfg['tests'] = [mock_test_bin, mock_test_bin_fail] + write_file(eb.logfile, '') + self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin_fail} failed', eb.test_cases_step) + log_txt = read_file(eb.logfile) + self.assertIn('Test case success', log_txt) + self.assertIn('Test case failure', log_txt) + def test_post_processing_step(self): """Test post_processing_step and deprecated post_install_step.""" init_config(build_options={'silent': True}) From 11e753d2f46cd40e453db413253edcf081edebe6 Mon Sep 17 00:00:00 2001 From: Kenneth Hoste Date: Wed, 8 Apr 2026 08:17:29 +0200 Subject: [PATCH 3/6] tweak error message when non-existing test command is specified --- easybuild/framework/easyblock.py | 2 +- test/framework/easyblock.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py index cebc2d0425..2cf2ba07c8 100644 --- a/easybuild/framework/easyblock.py +++ b/easybuild/framework/easyblock.py @@ -4671,7 +4671,7 @@ def test_cases_step(self): else: test_cmd = test if not os.path.exists(test_cmd): - raise EasyBuildError(f"Test specifies invalid path: {test_cmd}") + raise EasyBuildError(f"Test specifies non-existing path: {test_cmd}") if os.path.isfile(test_cmd): original_perms = os.lstat(test_cmd).st_mode diff --git a/test/framework/easyblock.py b/test/framework/easyblock.py index d84ca2dac3..3e21c43ffe 100644 --- a/test/framework/easyblock.py +++ b/test/framework/easyblock.py @@ -1285,9 +1285,9 @@ def test_test_cases_step(self): self.writeEC() eb = EasyBlock(EasyConfig(self.eb_file)) eb.cfg['tests'] = ['does-not-exist'] - self.assertRaisesRegex(EasyBuildError, 'invalid path: does-not-exist', eb.test_cases_step) + self.assertRaisesRegex(EasyBuildError, 'non-existing path: does-not-exist', eb.test_cases_step) eb.cfg['tests'] = ['/abs/path/does-not-exist'] - self.assertRaisesRegex(EasyBuildError, 'invalid path: /abs/path/does-not-exist', eb.test_cases_step) + self.assertRaisesRegex(EasyBuildError, 'non-existing path: /abs/path/does-not-exist', eb.test_cases_step) mock_test_bin = os.path.join(self.test_prefix, 'pi', 'test_me') os.environ['PATH'] += f':{os.path.dirname(mock_test_bin)}' @@ -1295,7 +1295,8 @@ def test_test_cases_step(self): adjust_permissions(mock_test_bin, stat.S_IXUSR) eb.cfg['tests'] = [os.path.basename(mock_test_bin)] - self.assertRaisesRegex(EasyBuildError, f'invalid path: {os.path.basename(mock_test_bin)}', eb.test_cases_step) + fn = os.path.basename(mock_test_bin) + self.assertRaisesRegex(EasyBuildError, f'non-existing path: {fn}', eb.test_cases_step) init_config(args=[f"--sourcepath={self.test_prefix}"]) write_file(eb.logfile, '') @@ -1318,7 +1319,6 @@ def test_test_cases_step(self): self.assertIn('Test case success', read_file(eb.logfile)) # Detect failure - adjust_permissions(mock_test_bin, stat.S_IWRITE) mock_test_bin_fail = mock_test_bin + "_fail" write_file(mock_test_bin_fail, "#!/bin/bash\necho 'Test case failure' && exit 1") eb.cfg['tests'] = [mock_test_bin_fail] From 707b8ace9bdaf4d716565c316a0bf259a4ed364a Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Wed, 8 Apr 2026 12:34:26 +0200 Subject: [PATCH 4/6] Simplify test command error handling Use a single try-except-finally block --- easybuild/framework/easyblock.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py index 2cf2ba07c8..5d56f83911 100644 --- a/easybuild/framework/easyblock.py +++ b/easybuild/framework/easyblock.py @@ -4682,14 +4682,12 @@ def test_cases_step(self): else: original_perms = None try: - self.log.debug(f"Running test {test_cmd}") - try: - run_shell_cmd(test_cmd) - finally: - if original_perms is not None: - adjust_permissions(test_cmd, original_perms, relative=False) - except BaseException as err: + run_shell_cmd(test_cmd) + except Exception as err: raise EasyBuildError(f"Running test {test_cmd} failed: {err}") + finally: + if original_perms is not None: + adjust_permissions(test_cmd, original_perms, relative=False) def update_config_template_run_step(self): """Update the the easyconfig template dictionary with easyconfig.TEMPLATE_NAMES_EASYBLOCK_RUN_STEP names""" From b989f6f6ce63be00afae011c496b2f823cfa764a Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Wed, 8 Apr 2026 13:53:23 +0200 Subject: [PATCH 5/6] Derive `RunShellCmdError` from `Exception` instead of `BaseException` As suggested by Python documentation --- easybuild/tools/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easybuild/tools/run.py b/easybuild/tools/run.py index a121b91acc..3505fff32f 100644 --- a/easybuild/tools/run.py +++ b/easybuild/tools/run.py @@ -101,7 +101,7 @@ """ -class RunShellCmdError(BaseException): +class RunShellCmdError(Exception): def __init__(self, cmd_result, caller_info, *args, **kwargs): """Constructor for RunShellCmdError.""" From a081c8588feb987048216274368f1753da099e77 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Wed, 8 Apr 2026 14:32:30 +0200 Subject: [PATCH 6/6] Let `RunShellCmdError` propagate from `test_cases_step` It will be printed at the end including command, environment and output. --- easybuild/framework/easyblock.py | 2 ++ test/framework/easyblock.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py index 5d56f83911..c2537992c8 100644 --- a/easybuild/framework/easyblock.py +++ b/easybuild/framework/easyblock.py @@ -4683,6 +4683,8 @@ def test_cases_step(self): original_perms = None try: run_shell_cmd(test_cmd) + except RunShellCmdError: + raise # Let that propagate which will report more information except Exception as err: raise EasyBuildError(f"Running test {test_cmd} failed: {err}") finally: diff --git a/test/framework/easyblock.py b/test/framework/easyblock.py index 3e21c43ffe..a811c7f68c 100644 --- a/test/framework/easyblock.py +++ b/test/framework/easyblock.py @@ -57,6 +57,7 @@ from easybuild.tools.filetools import remove_dir, remove_file, symlink, verify_checksum, write_file from easybuild.tools.module_generator import module_generator from easybuild.tools.modules import EnvironmentModules, Lmod, reset_module_caches +from easybuild.tools.run import RunShellCmdError from easybuild.tools.version import get_git_revision, this_is_easybuild @@ -1323,13 +1324,13 @@ def test_test_cases_step(self): write_file(mock_test_bin_fail, "#!/bin/bash\necho 'Test case failure' && exit 1") eb.cfg['tests'] = [mock_test_bin_fail] write_file(eb.logfile, '') - self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin_fail} failed', eb.test_cases_step) + self.assertRaisesRegex(RunShellCmdError, f"'{os.path.basename(mock_test_bin_fail)}' failed", eb.test_cases_step) self.assertIn('Test case failure', read_file(eb.logfile)) # Multiple tests eb.cfg['tests'] = [mock_test_bin, mock_test_bin_fail] write_file(eb.logfile, '') - self.assertRaisesRegex(EasyBuildError, f'Running test {mock_test_bin_fail} failed', eb.test_cases_step) + self.assertRaisesRegex(RunShellCmdError, f"'{os.path.basename(mock_test_bin_fail)}' failed", eb.test_cases_step) log_txt = read_file(eb.logfile) self.assertIn('Test case success', log_txt) self.assertIn('Test case failure', log_txt)