Skip to content

Commit 629b312

Browse files
authored
Merge pull request #4321 from boegel/run_modules
implement support in `run` function for splitting stdout and stderr output + passing down environment to use, and switch to `run` function for running `module` commands
2 parents 8099f22 + 61757c7 commit 629b312

File tree

4 files changed

+143
-57
lines changed

4 files changed

+143
-57
lines changed

easybuild/tools/modules.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from easybuild.tools.environment import ORIG_OS_ENVIRON, restore_env, setvar, unset_env_vars
5151
from easybuild.tools.filetools import convert_name, mkdir, normalize_path, path_matches, read_file, which, write_file
5252
from easybuild.tools.module_naming_scheme.mns import DEVEL_MODULE_SUFFIX
53-
from easybuild.tools.run import run_cmd, subprocess_popen_text
53+
from easybuild.tools.run import run
5454
from easybuild.tools.utilities import get_subclasses, nub
5555

5656
# software root/version environment variable name prefixes
@@ -307,31 +307,32 @@ def check_module_function(self, allow_mismatch=False, regex=None):
307307
if self.testing:
308308
# grab 'module' function definition from environment if it's there; only during testing
309309
if 'module' in os.environ:
310-
out, ec = os.environ['module'], 0
310+
output, exit_code = os.environ['module'], 0
311311
else:
312-
out, ec = None, 1
312+
output, exit_code = None, 1
313313
else:
314314
cmd = "type module"
315-
out, ec = run_cmd(cmd, simple=False, log_ok=False, log_all=False, force_in_dry_run=True, trace=False)
315+
res = run(cmd, fail_on_error=False, in_dry_run=False, hidden=True)
316+
output, exit_code = res.output, res.exit_code
316317

317318
if regex is None:
318319
regex = r".*%s" % os.path.basename(self.cmd)
319320
mod_cmd_re = re.compile(regex, re.M)
320321
mod_details = "pattern '%s' (%s)" % (mod_cmd_re.pattern, self.NAME)
321322

322-
if ec == 0:
323-
if mod_cmd_re.search(out):
323+
if exit_code == 0:
324+
if mod_cmd_re.search(output):
324325
self.log.debug("Found pattern '%s' in defined 'module' function." % mod_cmd_re.pattern)
325326
else:
326327
msg = "%s not found in defined 'module' function.\n" % mod_details
327328
msg += "Specify the correct modules tool to avoid weird problems due to this mismatch, "
328329
msg += "see the --modules-tool and --avail-modules-tools command line options.\n"
329330
if allow_mismatch:
330-
msg += "Obtained definition of 'module' function: %s" % out
331+
msg += "Obtained definition of 'module' function: %s" % output
331332
self.log.warning(msg)
332333
else:
333334
msg += "Or alternatively, use --allow-modules-tool-mismatch to stop treating this as an error. "
334-
msg += "Obtained definition of 'module' function: %s" % out
335+
msg += "Obtained definition of 'module' function: %s" % output
335336
raise EasyBuildError(msg)
336337
else:
337338
# module function may not be defined (weird, but fine)
@@ -821,24 +822,23 @@ def run_module(self, *args, **kwargs):
821822
key, old_value, new_value)
822823

823824
cmd_list = self.compose_cmd_list(args)
824-
full_cmd = ' '.join(cmd_list)
825-
self.log.debug("Running module command '%s' from %s" % (full_cmd, os.getcwd()))
826-
827-
proc = subprocess_popen_text(cmd_list, env=environ)
825+
cmd = ' '.join(cmd_list)
826+
# note: module commands are always run in dry mode, and are kept hidden in trace and dry run output
827+
res = run(cmd_list, env=environ, fail_on_error=False, shell=False, split_stderr=True,
828+
hidden=True, in_dry_run=True)
828829

829830
# stdout will contain python code (to change environment etc)
830831
# stderr will contain text (just like the normal module command)
831-
(stdout, stderr) = proc.communicate()
832-
self.log.debug("Output of module command '%s': stdout: %s; stderr: %s" % (full_cmd, stdout, stderr))
832+
stdout, stderr = res.output, res.stderr
833+
self.log.debug("Output of module command '%s': stdout: %s; stderr: %s", cmd, stdout, stderr)
833834

834835
# also catch and check exit code
835-
exit_code = proc.returncode
836-
if kwargs.get('check_exit_code', True) and exit_code != 0:
836+
if kwargs.get('check_exit_code', True) and res.exit_code != 0:
837837
raise EasyBuildError("Module command '%s' failed with exit code %s; stderr: %s; stdout: %s",
838-
' '.join(cmd_list), exit_code, stderr, stdout)
838+
cmd, res.exit_code, stderr, stdout)
839839

840840
if kwargs.get('check_output', True):
841-
self.check_module_output(full_cmd, stdout, stderr)
841+
self.check_module_output(cmd, stdout, stderr)
842842

843843
if kwargs.get('return_stderr', False):
844844
return stderr
@@ -1422,21 +1422,22 @@ def update(self):
14221422

14231423
if build_option('update_modules_tool_cache'):
14241424
spider_cmd = os.path.join(os.path.dirname(self.cmd), 'spider')
1425-
cmd = [spider_cmd, '-o', 'moduleT', os.environ['MODULEPATH']]
1426-
self.log.debug("Running command '%s'..." % ' '.join(cmd))
1425+
cmd_list = [spider_cmd, '-o', 'moduleT', os.environ['MODULEPATH']]
1426+
cmd = ' '.join(cmd_list)
1427+
self.log.debug("Running command '%s'...", cmd)
14271428

1428-
proc = subprocess_popen_text(cmd, env=os.environ)
1429-
(stdout, stderr) = proc.communicate()
1429+
res = run(cmd_list, env=os.environ, fail_on_error=False, shell=False, split_stderr=True, hidden=True)
1430+
stdout, stderr = res.output, res.stderr
14301431

14311432
if stderr:
1432-
raise EasyBuildError("An error occurred when running '%s': %s", ' '.join(cmd), stderr)
1433+
raise EasyBuildError("An error occurred when running '%s': %s", cmd, stderr)
14331434

14341435
if self.testing:
14351436
# don't actually update local cache when testing, just return the cache contents
14361437
return stdout
14371438
else:
14381439
cache_fp = os.path.join(self.USER_CACHE_DIR, 'moduleT.lua')
1439-
self.log.debug("Updating Lmod spider cache %s with output from '%s'" % (cache_fp, ' '.join(cmd)))
1440+
self.log.debug("Updating Lmod spider cache %s with output from '%s'", cache_fp, cmd)
14401441
cache_dir = os.path.dirname(cache_fp)
14411442
if not os.path.exists(cache_dir):
14421443
mkdir(cache_dir, parents=True)

easybuild/tools/run.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def cache_aware_func(cmd, *args, **kwargs):
107107

108108

109109
@run_cache
110-
def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
110+
def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,
111111
hidden=False, in_dry_run=False, work_dir=None, shell=True,
112112
output_file=False, stream_output=False, asynchronous=False,
113113
qa_patterns=None, qa_wait_patterns=None):
@@ -117,6 +117,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
117117
:param fail_on_error: fail on non-zero exit code (enabled by default)
118118
:param split_stderr: split of stderr from stdout output
119119
:param stdin: input to be sent to stdin (nothing if set to None)
120+
:param env: environment to use to run command (if None, inherit current process environment)
120121
:param hidden: do not show command in terminal output (when using --trace, or with --extended-dry-run / -x)
121122
:param in_dry_run: also run command in dry run mode
122123
:param work_dir: working directory to run command in (current working directory if None)
@@ -134,7 +135,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
134135
"""
135136

136137
# temporarily raise a NotImplementedError until all options are implemented
137-
if any((split_stderr, work_dir, stream_output, asynchronous)):
138+
if any((work_dir, stream_output, asynchronous)):
138139
raise NotImplementedError
139140

140141
if qa_patterns or qa_wait_patterns:
@@ -163,7 +164,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
163164
if not in_dry_run and build_option('extended_dry_run'):
164165
if not hidden:
165166
silent = build_option('silent')
166-
msg = f" running command \"{cmd_msg}s\"\n"
167+
msg = f" running command \"{cmd_msg}\"\n"
167168
msg += f" (in {work_dir})"
168169
dry_run_msg(msg, silent=silent)
169170

@@ -179,21 +180,27 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
179180

180181
# use bash as shell instead of the default /bin/sh used by subprocess.run
181182
# (which could be dash instead of bash, like on Ubuntu, see https://wiki.ubuntu.com/DashAsBinSh)
182-
if shell:
183-
executable = '/bin/bash'
184-
else:
185-
# stick to None (default value) when not running command via a shell
186-
executable = None
183+
# stick to None (default value) when not running command via a shell
184+
executable = '/bin/bash' if shell else None
185+
186+
stderr = subprocess.PIPE if split_stderr else subprocess.STDOUT
187187

188188
_log.info(f"Running command '{cmd_msg}' in {work_dir}")
189-
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=fail_on_error,
190-
input=stdin, shell=shell, executable=executable)
189+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=stderr, check=fail_on_error,
190+
env=env, input=stdin, shell=shell, executable=executable)
191191

192192
# return output as a regular string rather than a byte sequence (and non-UTF-8 characters get stripped out)
193193
output = proc.stdout.decode('utf-8', 'ignore')
194+
stderr_output = proc.stderr.decode('utf-8', 'ignore') if split_stderr else None
194195

195-
res = RunResult(output=output, exit_code=proc.returncode, stderr=None)
196-
_log.info(f"Command '{cmd_msg}' exited with exit code {res.exit_code} and output:\n{res.output}")
196+
res = RunResult(output=output, exit_code=proc.returncode, stderr=stderr_output)
197+
198+
if split_stderr:
199+
log_msg = f"Command '{cmd_msg}' exited with exit code {res.exit_code}, "
200+
log_msg += f"with stdout:\n{res.output}\nstderr:\n{res.stderr}"
201+
else:
202+
log_msg = f"Command '{cmd_msg}' exited with exit code {res.exit_code} and output:\n{res.output}"
203+
_log.info(log_msg)
197204

198205
if not hidden:
199206
time_since_start = time_str_since(start_time)

test/framework/modules.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from easybuild.tools.modules import EnvironmentModules, EnvironmentModulesC, EnvironmentModulesTcl, Lmod, NoModulesTool
5151
from easybuild.tools.modules import curr_module_paths, get_software_libdir, get_software_root, get_software_version
5252
from easybuild.tools.modules import invalidate_module_caches_for, modules_tool, reset_module_caches
53-
from easybuild.tools.run import run_cmd
53+
from easybuild.tools.run import run
5454

5555

5656
# number of modules included for testing purposes
@@ -191,6 +191,21 @@ def test_run_module(self):
191191
regex = re.compile(r'^os\.environ\[', re.M)
192192
self.assertFalse(regex.search(out), "Pattern '%s' should not be found in: %s" % (regex.pattern, out))
193193

194+
def test_list(self):
195+
"""
196+
Test running 'module list' via ModulesTool instance.
197+
"""
198+
# make very sure no modules are currently loaded
199+
self.modtool.run_module('purge', '--force')
200+
201+
out = self.modtool.list()
202+
self.assertEqual(out, [])
203+
204+
mods = ['GCC/7.3.0-2.30']
205+
self.modtool.load(mods)
206+
out = self.modtool.list()
207+
self.assertEqual([x['mod_name'] for x in out], mods)
208+
194209
def test_avail(self):
195210
"""Test if getting a (restricted) list of available modules works."""
196211
self.init_testmods()
@@ -1317,9 +1332,9 @@ def test_module_use_bash(self):
13171332
self.assertIn(modules_dir, modulepath)
13181333

13191334
with self.mocked_stdout_stderr():
1320-
out, _ = run_cmd("bash -c 'echo MODULEPATH: $MODULEPATH'", simple=False)
1321-
self.assertEqual(out.strip(), "MODULEPATH: %s" % modulepath)
1322-
self.assertIn(modules_dir, out)
1335+
res = run("bash -c 'echo MODULEPATH: $MODULEPATH'")
1336+
self.assertEqual(res.output.strip(), f"MODULEPATH: {modulepath}")
1337+
self.assertIn(modules_dir, res.output)
13231338

13241339
def test_load_in_hierarchy(self):
13251340
"""Test whether loading a module in a module hierarchy results in loading the correct module."""

test/framework/run.py

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,28 @@ def test_run_cmd_log_output(self):
369369
self.assertTrue(out.startswith('foo ') and out.endswith(' bar'))
370370
self.assertEqual(type(out), str)
371371

372+
def test_run_split_stderr(self):
373+
"""Test getting split stdout/stderr output from run function."""
374+
cmd = ';'.join([
375+
"echo ok",
376+
"echo warning >&2",
377+
])
378+
379+
# by default, output contains both stdout + stderr
380+
with self.mocked_stdout_stderr():
381+
res = run(cmd)
382+
self.assertEqual(res.exit_code, 0)
383+
output_lines = res.output.split('\n')
384+
self.assertTrue("ok" in output_lines)
385+
self.assertTrue("warning" in output_lines)
386+
self.assertEqual(res.stderr, None)
387+
388+
with self.mocked_stdout_stderr():
389+
res = run(cmd, split_stderr=True)
390+
self.assertEqual(res.exit_code, 0)
391+
self.assertEqual(res.stderr, "warning\n")
392+
self.assertEqual(res.output, "ok\n")
393+
372394
def test_run_cmd_trace(self):
373395
"""Test run_cmd in trace mode, and with tracing disabled."""
374396

@@ -778,55 +800,96 @@ def test_parse_log_error(self):
778800
errors = parse_log_for_error("error failed", True)
779801
self.assertEqual(len(errors), 1)
780802

781-
def test_dry_run(self):
782-
"""Test use of functions under (extended) dry run."""
803+
def test_run_cmd_dry_run(self):
804+
"""Test use of run_cmd function under (extended) dry run."""
783805
build_options = {
784806
'extended_dry_run': True,
785807
'silent': False,
786808
}
787809
init_config(build_options=build_options)
788810

811+
cmd = "somecommand foo 123 bar"
812+
789813
self.mock_stdout(True)
790-
run_cmd("somecommand foo 123 bar")
791-
txt = self.get_stdout()
814+
run_cmd(cmd)
815+
stdout = self.get_stdout()
792816
self.mock_stdout(False)
793817

794818
expected = """ running command "somecommand foo 123 bar"\n"""
795-
self.assertIn(expected, txt)
819+
self.assertIn(expected, stdout)
796820

797821
# check disabling 'verbose'
798822
self.mock_stdout(True)
799823
run_cmd("somecommand foo 123 bar", verbose=False)
824+
stdout = self.get_stdout()
800825
self.mock_stdout(False)
826+
self.assertNotIn(expected, stdout)
801827

802828
# check forced run_cmd
803829
outfile = os.path.join(self.test_prefix, 'cmd.out')
804830
self.assertNotExists(outfile)
805831
self.mock_stdout(True)
806832
run_cmd("echo 'This is always echoed' > %s" % outfile, force_in_dry_run=True)
807-
txt = self.get_stdout()
808833
self.mock_stdout(False)
809834
self.assertExists(outfile)
810835
self.assertEqual(read_file(outfile), "This is always echoed\n")
811836

812-
write_file(outfile, '', forced=True)
837+
# Q&A commands
838+
self.mock_stdout(True)
839+
run_cmd_qa("some_qa_cmd", {'question1': 'answer1'})
840+
stdout = self.get_stdout()
841+
self.mock_stdout(False)
842+
843+
expected = """ running interactive command "some_qa_cmd"\n"""
844+
self.assertIn(expected, stdout)
845+
846+
def test_run_dry_run(self):
847+
"""Test use of run function under (extended) dry run."""
848+
build_options = {
849+
'extended_dry_run': True,
850+
'silent': False,
851+
}
852+
init_config(build_options=build_options)
853+
854+
cmd = "somecommand foo 123 bar"
813855

814-
# check forced run
815856
self.mock_stdout(True)
816-
run("echo 'This is always echoed' > %s" % outfile, in_dry_run=True)
817-
txt = self.get_stdout()
857+
res = run(cmd)
858+
stdout = self.get_stdout()
818859
self.mock_stdout(False)
819-
self.assertExists(outfile)
820-
self.assertEqual(read_file(outfile), "This is always echoed\n")
860+
# fake output/exit code is returned for commands not actually run in dry run mode
861+
self.assertEqual(res.exit_code, 0)
862+
self.assertEqual(res.output, '')
863+
self.assertEqual(res.stderr, None)
864+
# check dry run output
865+
expected = """ running command "somecommand foo 123 bar"\n"""
866+
self.assertIn(expected, stdout)
821867

822-
# Q&A commands
868+
# check enabling 'hidden'
823869
self.mock_stdout(True)
824-
run_cmd_qa("some_qa_cmd", {'question1': 'answer1'})
825-
txt = self.get_stdout()
870+
res = run(cmd, hidden=True)
871+
stdout = self.get_stdout()
826872
self.mock_stdout(False)
873+
# fake output/exit code is returned for commands not actually run in dry run mode
874+
self.assertEqual(res.exit_code, 0)
875+
self.assertEqual(res.output, '')
876+
self.assertEqual(res.stderr, None)
877+
# dry run output should be missing
878+
self.assertNotIn(expected, stdout)
827879

828-
expected = """ running interactive command "some_qa_cmd"\n"""
829-
self.assertIn(expected, txt)
880+
# check forced run_cmd
881+
outfile = os.path.join(self.test_prefix, 'cmd.out')
882+
self.assertNotExists(outfile)
883+
self.mock_stdout(True)
884+
res = run("echo 'This is always echoed' > %s; echo done; false" % outfile, fail_on_error=False, in_dry_run=True)
885+
stdout = self.get_stdout()
886+
self.mock_stdout(False)
887+
self.assertNotIn('running command "', stdout)
888+
self.assertNotEqual(res.exit_code, 0)
889+
self.assertEqual(res.output, 'done\n')
890+
self.assertEqual(res.stderr, None)
891+
self.assertExists(outfile)
892+
self.assertEqual(read_file(outfile), "This is always echoed\n")
830893

831894
def test_run_cmd_list(self):
832895
"""Test run_cmd with command specified as a list rather than a string"""

0 commit comments

Comments
 (0)