Skip to content

Commit df3b72c

Browse files
committed
separate raising of RunShellCmdError from printing info on failed shell command, log command output before log message that mentions success of failure for command
1 parent 2119ff4 commit df3b72c

File tree

2 files changed

+148
-71
lines changed

2 files changed

+148
-71
lines changed

easybuild/tools/run.py

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,69 @@
8080
RunShellCmdResult = namedtuple('RunShellCmdResult', ('cmd', 'exit_code', 'output', 'stderr', 'work_dir'))
8181

8282

83-
def report_run_shell_cmd_error(cmd, exit_code, work_dir, output, stderr):
83+
class RunShellCmdError(BaseException):
84+
85+
def __init__(self, cmd, exit_code, work_dir, output, stderr, caller_info, *args, **kwargs):
86+
"""Constructor for RunShellCmdError."""
87+
self.cmd = cmd
88+
self.cmd_name = cmd.split(' ')[0]
89+
self.exit_code = exit_code
90+
self.work_dir = work_dir
91+
self.output = output
92+
self.stderr = stderr
93+
self.caller_info = caller_info
94+
95+
msg = f"Shell command '{self.cmd_name}' failed!"
96+
super(RunShellCmdError, self).__init__(msg, *args, **kwargs)
97+
98+
99+
def print_run_shell_cmd_error(err):
84100
"""
85-
Report error that occurred when running a shell command.
101+
Report failed shell command using provided RunShellCmdError instance
86102
"""
87-
cmd_name = cmd.split(' ')[0]
103+
104+
def pad_4_spaces(msg):
105+
return ' ' * 4 + msg
106+
107+
cmd_name = err.cmd.split(' ')[0]
108+
error_info = [
109+
'',
110+
f"ERROR: Shell command failed!",
111+
pad_4_spaces(f"full command -> {err.cmd}"),
112+
pad_4_spaces(f"exit code -> {err.exit_code}"),
113+
pad_4_spaces(f"working directory -> {err.work_dir}"),
114+
]
88115

89116
tmpdir = tempfile.mkdtemp(prefix='shell-cmd-error-')
90117
output_fp = os.path.join(tmpdir, f"{cmd_name}.out")
91118
with open(output_fp, 'w') as fp:
92-
fp.write(output or '')
93-
stderr_fp = os.path.join(tmpdir, f"{cmd_name}.err")
94-
with open(stderr_fp, 'w') as fp:
95-
fp.write(stderr or '')
119+
fp.write(err.output or '')
120+
121+
if err.stderr is None:
122+
error_info.append(pad_4_spaces(f"output (stdout + stderr) -> {output_fp}"))
123+
else:
124+
stderr_fp = os.path.join(tmpdir, f"{cmd_name}.err")
125+
with open(stderr_fp, 'w') as fp:
126+
fp.write(err.stderr)
127+
error_info.extend([
128+
pad_4_spaces(f"output (stdout) -> {output_fp}"),
129+
pad_4_spaces(f"error/warnings (stderr) -> {stderr_fp}"),
130+
])
131+
132+
caller_file_name, caller_line_nr, caller_function_name = err.caller_info
133+
called_from_info = f"'{caller_function_name}' function in {caller_file_name} (line {caller_line_nr})"
134+
error_info.extend([
135+
pad_4_spaces(f"called from -> {called_from_info}"),
136+
'',
137+
])
138+
139+
sys.stderr.write('\n'.join(error_info) + '\n')
140+
141+
142+
def raise_run_shell_cmd_error(cmd, exit_code, work_dir, output, stderr):
143+
"""
144+
Raise RunShellCmdError for failing shell command, after collecting additional caller info
145+
"""
96146

97147
# figure out where failing command was run
98148
# need to go 3 levels down:
@@ -104,30 +154,9 @@ def report_run_shell_cmd_error(cmd, exit_code, work_dir, output, stderr):
104154
caller_file_name = frameinfo.filename
105155
caller_line_nr = frameinfo.lineno
106156
caller_function_name = frameinfo.function
157+
caller_info = (frameinfo.filename, frameinfo.lineno, frameinfo.function)
107158

108-
error_info = [
109-
f"| full shell command | {cmd}",
110-
f"| exit code | {exit_code}",
111-
f"| working directory | {work_dir}",
112-
]
113-
if stderr is None:
114-
error_info.append(f"| output (stdout + stderr) | {output_fp}")
115-
else:
116-
error_info.extend([
117-
f"| output (stdout) | {output_fp}",
118-
f"| error/warnings (stderr) | {stderr_fp}",
119-
])
120-
121-
called_from_info = f"{caller_function_name} function in {caller_file_name} (line {caller_line_nr})"
122-
error_info.append(f"| called from | {called_from_info}")
123-
124-
error_msg = '\n'.join([''] + error_info + [
125-
'',
126-
f"ERROR: shell command '{cmd_name}' failed!",
127-
'',
128-
])
129-
sys.stderr.write(error_msg)
130-
sys.exit(exit_code)
159+
raise RunShellCmdError(cmd, exit_code, work_dir, output, stderr, caller_info)
131160

132161

133162
def run_cmd_cache(func):
@@ -265,8 +294,20 @@ def to_cmd_str(cmd):
265294

266295
res = RunShellCmdResult(cmd=cmd_str, exit_code=proc.returncode, output=output, stderr=stderr, work_dir=work_dir)
267296

268-
if res.exit_code != 0 and fail_on_error:
269-
report_run_shell_cmd_error(res.cmd, res.exit_code, res.work_dir, output=res.output, stderr=res.stderr)
297+
# always log command output
298+
cmd_name = cmd_str.split(' ')[0]
299+
if split_stderr:
300+
_log.info(f"Output of '{cmd_name} ...' shell command (stdout only):\n{res.output}")
301+
_log.info(f"Warnings and errors of '{cmd_name} ...' shell command (stderr only):\n{res.stderr}")
302+
else:
303+
_log.info(f"Output of '{cmd_name} ...' shell command (stdout + stderr):\n{res.output}")
304+
305+
if res.exit_code == 0:
306+
_log.info(f"Shell command completed successfully (see output above): {cmd_str}")
307+
else:
308+
_log.warning(f"Shell command FAILED (exit code {res.exit_code}, see output above): {cmd_str}")
309+
if fail_on_error:
310+
raise_run_shell_cmd_error(res.cmd, res.exit_code, res.work_dir, output=res.output, stderr=res.stderr)
270311

271312
if with_hooks:
272313
run_hook_kwargs = {
@@ -277,13 +318,6 @@ def to_cmd_str(cmd):
277318
}
278319
run_hook(RUN_SHELL_CMD, hooks, post_step_hook=True, args=[cmd], kwargs=run_hook_kwargs)
279320

280-
if split_stderr:
281-
log_msg = f"Command '{cmd_str}' exited with exit code {res.exit_code}, "
282-
log_msg += f"with stdout:\n{res.output}\nstderr:\n{res.stderr}"
283-
else:
284-
log_msg = f"Command '{cmd_str}' exited with exit code {res.exit_code} and output:\n{res.output}"
285-
_log.info(log_msg)
286-
287321
if not hidden:
288322
time_since_start = time_str_since(start_time)
289323
trace_msg(f"command completed: exit {res.exit_code}, ran in {time_since_start}")

test/framework/run.py

Lines changed: 75 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
from easybuild.tools.build_log import EasyBuildError, init_logging, stop_logging
5151
from easybuild.tools.config import update_build_option
5252
from easybuild.tools.filetools import adjust_permissions, change_dir, mkdir, read_file, write_file
53-
from easybuild.tools.run import RunShellCmdResult, check_async_cmd, check_log_for_errors, complete_cmd
54-
from easybuild.tools.run import get_output_from_process, parse_log_for_error, run_shell_cmd, run_cmd, run_cmd_qa
55-
from easybuild.tools.run import subprocess_terminate
53+
from easybuild.tools.run import RunShellCmdResult, RunShellCmdError, check_async_cmd, check_log_for_errors
54+
from easybuild.tools.run import complete_cmd, get_output_from_process, parse_log_for_error
55+
from easybuild.tools.run import print_run_shell_cmd_error, run_cmd, run_cmd_qa, run_shell_cmd, subprocess_terminate
5656
from easybuild.tools.config import ERROR, IGNORE, WARN
5757

5858

@@ -311,6 +311,9 @@ def test_run_shell_cmd_fail(self):
311311
def handler(signum, _):
312312
raise RuntimeError("Signal handler called with signal %s" % signum)
313313

314+
# disable trace output for this test (so stdout remains empty)
315+
update_build_option('trace', False)
316+
314317
orig_sigalrm_handler = signal.getsignal(signal.SIGALRM)
315318

316319
try:
@@ -321,38 +324,78 @@ def handler(signum, _):
321324
# command to kill parent shell
322325
cmd = "kill -9 $$"
323326

324-
workdir = os.path.realpath(self.test_prefix)
325-
change_dir(workdir)
327+
work_dir = os.path.realpath(self.test_prefix)
328+
change_dir(work_dir)
326329

327-
with self.mocked_stdout_stderr() as (_, stderr):
328-
self.assertErrorRegex(SystemExit, '.*', run_shell_cmd, cmd)
329-
330-
# check error reporting output
331-
stderr = stderr.getvalue()
332-
patterns = [
333-
r"^\| full shell command[ ]*\| kill -9 \$\$",
334-
r"^\| exit code[ ]*\| -9",
335-
r"^\| working directory[ ]*\| " + workdir,
336-
r"^\| called from[ ]*\| assertErrorRegex function in .*/easybuild/base/testing.py \(line [0-9]+\)",
337-
r"^ERROR: shell command 'kill' failed!",
338-
r"^\| output \(stdout \+ stderr\)[ ]*\| .*/shell-cmd-error-.*/kill.out",
339-
]
340-
for pattern in patterns:
341-
regex = re.compile(pattern, re.M)
342-
self.assertTrue(regex.search(stderr), "Pattern '%s' should be found in: %s" % (regex.pattern, stderr))
330+
try:
331+
run_shell_cmd(cmd)
332+
self.assertFalse("This should never be reached, RunShellCmdError should occur!")
333+
except RunShellCmdError as err:
334+
self.assertEqual(str(err), "Shell command 'kill' failed!")
335+
self.assertEqual(err.cmd, "kill -9 $$")
336+
self.assertEqual(err.cmd_name, 'kill')
337+
self.assertEqual(err.exit_code, -9)
338+
self.assertEqual(err.work_dir, work_dir)
339+
self.assertEqual(err.output, '')
340+
self.assertEqual(err.stderr, None)
341+
self.assertTrue(isinstance(err.caller_info, tuple))
342+
self.assertEqual(len(err.caller_info), 3)
343+
self.assertEqual(err.caller_info[0], __file__)
344+
self.assertTrue(isinstance(err.caller_info[1], int)) # line number of calling site
345+
self.assertEqual(err.caller_info[2], 'test_run_shell_cmd_fail')
346+
347+
with self.mocked_stdout_stderr() as (_, stderr):
348+
print_run_shell_cmd_error(err)
349+
350+
# check error reporting output
351+
stderr = stderr.getvalue()
352+
patterns = [
353+
r"^ERROR: Shell command failed!",
354+
r"^\s+full command\s* -> kill -9 \$\$",
355+
r"^\s+exit code\s* -> -9",
356+
r"^\s+working directory\s* -> " + work_dir,
357+
r"^\s+called from\s* -> 'test_run_shell_cmd_fail' function in .*/test/.*/run.py \(line [0-9]+\)",
358+
r"^\s+output \(stdout \+ stderr\)\s* -> .*/shell-cmd-error-.*/kill.out",
359+
]
360+
for pattern in patterns:
361+
regex = re.compile(pattern, re.M)
362+
self.assertTrue(regex.search(stderr), "Pattern '%s' should be found in: %s" % (pattern, stderr))
343363

344364
# check error reporting output when stdout/stderr are collected separately
345-
with self.mocked_stdout_stderr() as (_, stderr):
346-
self.assertErrorRegex(SystemExit, '.*', run_shell_cmd, cmd, split_stderr=True)
347-
stderr = stderr.getvalue()
348-
patterns.pop(-1)
349-
patterns.extend([
350-
r"^\| output \(stdout\)[ ]*\| .*/shell-cmd-error-.*/kill.out",
351-
r"^\| error/warnings \(stderr\)[ ]*\| .*/shell-cmd-error-.*/kill.err",
352-
])
353-
for pattern in patterns:
354-
regex = re.compile(pattern, re.M)
355-
self.assertTrue(regex.search(stderr), "Pattern '%s' should be found in: %s" % (regex.pattern, stderr))
365+
try:
366+
run_shell_cmd(cmd, split_stderr=True)
367+
self.assertFalse("This should never be reached, RunShellCmdError should occur!")
368+
except RunShellCmdError as err:
369+
self.assertEqual(str(err), "Shell command 'kill' failed!")
370+
self.assertEqual(err.cmd, "kill -9 $$")
371+
self.assertEqual(err.cmd_name, 'kill')
372+
self.assertEqual(err.exit_code, -9)
373+
self.assertEqual(err.work_dir, work_dir)
374+
self.assertEqual(err.output, '')
375+
self.assertEqual(err.stderr, '')
376+
self.assertTrue(isinstance(err.caller_info, tuple))
377+
self.assertEqual(len(err.caller_info), 3)
378+
self.assertEqual(err.caller_info[0], __file__)
379+
self.assertTrue(isinstance(err.caller_info[1], int)) # line number of calling site
380+
self.assertEqual(err.caller_info[2], 'test_run_shell_cmd_fail')
381+
382+
with self.mocked_stdout_stderr() as (_, stderr):
383+
print_run_shell_cmd_error(err)
384+
385+
# check error reporting output
386+
stderr = stderr.getvalue()
387+
patterns = [
388+
r"^ERROR: Shell command failed!",
389+
r"^\s+full command\s+ -> kill -9 \$\$",
390+
r"^\s+exit code\s+ -> -9",
391+
r"^\s+working directory\s+ -> " + work_dir,
392+
r"^\s+called from\s+ -> 'test_run_shell_cmd_fail' function in .*/test/.*/run.py \(line [0-9]+\)",
393+
r"^\s+output \(stdout\)\s+ -> .*/shell-cmd-error-.*/kill.out",
394+
r"^\s+error/warnings \(stderr\)\s+ -> .*/shell-cmd-error-.*/kill.err",
395+
]
396+
for pattern in patterns:
397+
regex = re.compile(pattern, re.M)
398+
self.assertTrue(regex.search(stderr), "Pattern '%s' should be found in: %s" % (pattern, stderr))
356399

357400
# no error reporting when fail_on_error is disabled
358401
with self.mocked_stdout_stderr() as (_, stderr):

0 commit comments

Comments
 (0)