Skip to content

Commit 50457c3

Browse files
authored
Merge pull request #4284 from boegel/run
initial implementation of `run` function to replace `run_cmd` + `run_cmd_qa`
2 parents 9e78ca3 + 3f738f4 commit 50457c3

File tree

2 files changed

+233
-5
lines changed

2 files changed

+233
-5
lines changed

easybuild/tools/run.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import sys
4545
import tempfile
4646
import time
47+
from collections import namedtuple
4748
from datetime import datetime
4849

4950
import easybuild.tools.asyncprocess as asyncprocess
@@ -73,6 +74,117 @@
7374
]
7475

7576

77+
RunResult = namedtuple('RunResult', ('output', 'exit_code', 'stderr'))
78+
79+
80+
def run(cmd, fail_on_error=True, split_stderr=False, stdin=None,
81+
hidden=False, in_dry_run=False, work_dir=None, shell=True,
82+
output_file=False, stream_output=False, asynchronous=False,
83+
qa_patterns=None, qa_wait_patterns=None):
84+
"""
85+
Run specified (interactive) shell command, and capture output + exit code.
86+
87+
:param fail_on_error: fail on non-zero exit code (enabled by default)
88+
:param split_stderr: split of stderr from stdout output
89+
:param stdin: input to be sent to stdin (nothing if set to None)
90+
:param hidden: do not show command in terminal output (when using --trace, or with --extended-dry-run / -x)
91+
:param in_dry_run: also run command in dry run mode
92+
:param work_dir: working directory to run command in (current working directory if None)
93+
:param shell: execute command through a shell (enabled by default)
94+
:param output_file: collect command output in temporary output file
95+
:param stream_output: stream command output to stdout
96+
:param asynchronous: run command asynchronously
97+
:param qa_patterns: list of 2-tuples with patterns for questions + corresponding answers
98+
:param qa_wait_patterns: list of 2-tuples with patterns for non-questions
99+
and number of iterations to allow these patterns to match with end out command output
100+
:return: Named tuple with:
101+
- output: command output, stdout+stderr combined if split_stderr is disabled, only stdout otherwise
102+
- exit_code: exit code of command (integer)
103+
- stderr: stderr output if split_stderr is enabled, None otherwise
104+
"""
105+
106+
# temporarily raise a NotImplementedError until all options are implemented
107+
if any((not fail_on_error, split_stderr, in_dry_run, work_dir, output_file, stream_output, asynchronous)):
108+
raise NotImplementedError
109+
110+
if qa_patterns or qa_wait_patterns:
111+
raise NotImplementedError
112+
113+
if isinstance(cmd, str):
114+
cmd_msg = cmd.strip()
115+
elif isinstance(cmd, list):
116+
cmd_msg = ' '.join(cmd)
117+
else:
118+
raise EasyBuildError(f"Unknown command type ('{type(cmd)}'): {cmd}")
119+
120+
silent = build_option('silent')
121+
122+
if work_dir is None:
123+
work_dir = os.getcwd()
124+
125+
# output file for command output (only used if output_file is enabled)
126+
cmd_out_fp = None
127+
128+
# early exit in 'dry run' mode, after printing the command that would be run (unless 'hidden' is enabled)
129+
if build_option('extended_dry_run'):
130+
if not hidden:
131+
msg = f" running command \"%{cmd_msg}s\"\n"
132+
msg += f" (in %{work_dir})"
133+
dry_run_msg(msg, silent=silent)
134+
135+
return RunResult(output='', exit_code=0, stderr=None)
136+
137+
start_time = datetime.now()
138+
if not hidden:
139+
cmd_trace_msg(cmd_msg, start_time, work_dir, stdin, cmd_out_fp)
140+
141+
if stdin:
142+
# 'input' value fed to subprocess.run must be a byte sequence
143+
stdin = stdin.encode()
144+
145+
_log.info(f"Running command '{cmd_msg}' in {work_dir}")
146+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, input=stdin, shell=shell)
147+
148+
# return output as a regular string rather than a byte sequence (and non-UTF-8 characters get stripped out)
149+
output = proc.stdout.decode('utf-8', 'ignore')
150+
151+
res = RunResult(output=output, exit_code=proc.returncode, stderr=None)
152+
_log.info(f"Command '{cmd_msg}' exited with exit code {res.exit_code} and output:\n%{res.output}")
153+
154+
if not hidden:
155+
time_since_start = time_str_since(start_time)
156+
trace_msg(f"command completed: exit {res.exit_code}, ran in {time_since_start}")
157+
158+
return res
159+
160+
161+
def cmd_trace_msg(cmd, start_time, work_dir, stdin, cmd_out_fp):
162+
"""
163+
Helper function to construct and print trace message for command being run
164+
165+
:param cmd: command being run
166+
:param start_time: datetime object indicating when command was started
167+
:param work_dir: path of working directory in which command is run
168+
:param stdin: stdin input value for command
169+
:param cmd_out_fp: path to output log file for command
170+
"""
171+
start_time = start_time.strftime('%Y-%m-%d %H:%M:%S')
172+
173+
lines = [
174+
"running command:",
175+
f"\t[started at: {start_time}]",
176+
f"\t[working dir: {work_dir}]",
177+
]
178+
if stdin:
179+
lines.append(f"\t[input: {stdin}]")
180+
if cmd_out_fp:
181+
lines.append(f"\t[output logged in {cmd_out_fp}]")
182+
183+
lines.append('\t' + cmd)
184+
185+
trace_msg('\n'.join(lines))
186+
187+
76188
def run_cmd_cache(func):
77189
"""Function decorator to cache (and retrieve cached) results of running commands."""
78190
cache = {}

test/framework/run.py

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
import easybuild.tools.asyncprocess as asyncprocess
4848
import easybuild.tools.utilities
4949
from easybuild.tools.build_log import EasyBuildError, init_logging, stop_logging
50-
from easybuild.tools.filetools import adjust_permissions, read_file, write_file
50+
from easybuild.tools.filetools import adjust_permissions, mkdir, read_file, write_file
5151
from easybuild.tools.run import check_async_cmd, check_log_for_errors, complete_cmd, get_output_from_process
52-
from easybuild.tools.run import parse_log_for_error, run_cmd, run_cmd_qa, subprocess_terminate
52+
from easybuild.tools.run import parse_log_for_error, run, run_cmd, run_cmd_qa, subprocess_terminate
5353
from easybuild.tools.config import ERROR, IGNORE, WARN
5454

5555

@@ -159,6 +159,32 @@ def test_run_cmd(self):
159159
self.assertTrue(out.startswith('foo ') and out.endswith(' bar'))
160160
self.assertEqual(type(out), str)
161161

162+
def test_run_basic(self):
163+
"""Basic test for run function."""
164+
165+
with self.mocked_stdout_stderr():
166+
res = run("echo hello")
167+
self.assertEqual(res.output, "hello\n")
168+
# no reason echo hello could fail
169+
self.assertEqual(res.exit_code, 0)
170+
self.assertEqual(type(res.output), str)
171+
172+
# test running command that emits non-UTF-8 characters
173+
# this is constructed to reproduce errors like:
174+
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2
175+
# UnicodeEncodeError: 'ascii' codec can't encode character u'\u2018'
176+
# (such errors are ignored by the 'run' implementation)
177+
for text in [b"foo \xe2 bar", b"foo \u2018 bar"]:
178+
test_file = os.path.join(self.test_prefix, 'foo.txt')
179+
write_file(test_file, text)
180+
cmd = "cat %s" % test_file
181+
182+
with self.mocked_stdout_stderr():
183+
res = run(cmd)
184+
self.assertEqual(res.exit_code, 0)
185+
self.assertTrue(res.output.startswith('foo ') and res.output.endswith(' bar'))
186+
self.assertEqual(type(res.output), str)
187+
162188
def test_run_cmd_log(self):
163189
"""Test logging of executed commands."""
164190
fd, logfile = tempfile.mkstemp(suffix='.log', prefix='eb-test-')
@@ -200,14 +226,47 @@ def test_run_cmd_log(self):
200226

201227
# Test that we can set the directory for the logfile
202228
log_path = os.path.join(self.test_prefix, 'chicken')
203-
os.mkdir(log_path)
229+
mkdir(log_path)
204230
logfile = None
205231
init_logging(logfile, silent=True, tmp_logdir=log_path)
206232
logfiles = os.listdir(log_path)
207233
self.assertEqual(len(logfiles), 1)
208234
self.assertTrue(logfiles[0].startswith("easybuild"))
209235
self.assertTrue(logfiles[0].endswith("log"))
210236

237+
def test_run_log(self):
238+
"""Test logging of executed commands with run function."""
239+
240+
fd, logfile = tempfile.mkstemp(suffix='.log', prefix='eb-test-')
241+
os.close(fd)
242+
243+
regex_start_cmd = re.compile("Running command 'echo hello' in /")
244+
regex_cmd_exit = re.compile("Command 'echo hello' exited with exit code [0-9]* and output:")
245+
246+
# command output is always logged
247+
init_logging(logfile, silent=True)
248+
with self.mocked_stdout_stderr():
249+
res = run("echo hello")
250+
stop_logging(logfile)
251+
self.assertEqual(res.exit_code, 0)
252+
self.assertEqual(res.output, 'hello\n')
253+
self.assertEqual(len(regex_start_cmd.findall(read_file(logfile))), 1)
254+
self.assertEqual(len(regex_cmd_exit.findall(read_file(logfile))), 1)
255+
write_file(logfile, '')
256+
257+
# with debugging enabled, exit code and output of command should only get logged once
258+
setLogLevelDebug()
259+
260+
init_logging(logfile, silent=True)
261+
with self.mocked_stdout_stderr():
262+
res = run("echo hello")
263+
stop_logging(logfile)
264+
self.assertEqual(res.exit_code, 0)
265+
self.assertEqual(res.output, 'hello\n')
266+
self.assertEqual(len(regex_start_cmd.findall(read_file(logfile))), 1)
267+
self.assertEqual(len(regex_cmd_exit.findall(read_file(logfile))), 1)
268+
write_file(logfile, '')
269+
211270
def test_run_cmd_negative_exit_code(self):
212271
"""Test run_cmd function with command that has negative exit code."""
213272
# define signal handler to call in case run_cmd takes too long
@@ -281,8 +340,6 @@ def test_run_cmd_log_output(self):
281340

282341
def test_run_cmd_trace(self):
283342
"""Test run_cmd under --trace"""
284-
# replace log.experimental with log.warning to allow experimental code
285-
easybuild.tools.utilities._log.experimental = easybuild.tools.utilities._log.warning
286343

287344
init_config(build_options={'trace': True})
288345

@@ -302,6 +359,7 @@ def test_run_cmd_trace(self):
302359
stderr = self.get_stderr()
303360
self.mock_stdout(False)
304361
self.mock_stderr(False)
362+
self.assertEqual(out, 'hello\n')
305363
self.assertEqual(ec, 0)
306364
self.assertEqual(stderr, '')
307365
regex = re.compile('\n'.join(pattern))
@@ -315,6 +373,7 @@ def test_run_cmd_trace(self):
315373
stderr = self.get_stderr()
316374
self.mock_stdout(False)
317375
self.mock_stderr(False)
376+
self.assertEqual(out, 'hello')
318377
self.assertEqual(ec, 0)
319378
self.assertEqual(stderr, '')
320379
pattern.insert(3, r"\t\[input: hello\]")
@@ -330,6 +389,63 @@ def test_run_cmd_trace(self):
330389
stderr = self.get_stderr()
331390
self.mock_stdout(False)
332391
self.mock_stderr(False)
392+
self.assertEqual(out, 'hello\n')
393+
self.assertEqual(ec, 0)
394+
self.assertEqual(stdout, '')
395+
self.assertEqual(stderr, '')
396+
397+
def test_run_trace_stdin(self):
398+
"""Test run under --trace + passing stdin input."""
399+
400+
init_config(build_options={'trace': True})
401+
402+
pattern = [
403+
r"^ >> running command:",
404+
r"\t\[started at: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]\]",
405+
r"\t\[working dir: .*\]",
406+
r"\techo hello",
407+
r" >> command completed: exit 0, ran in .*",
408+
]
409+
410+
self.mock_stdout(True)
411+
self.mock_stderr(True)
412+
res = run("echo hello")
413+
stdout = self.get_stdout()
414+
stderr = self.get_stderr()
415+
self.mock_stdout(False)
416+
self.mock_stderr(False)
417+
self.assertEqual(res.output, 'hello\n')
418+
self.assertEqual(res.exit_code, 0)
419+
self.assertEqual(stderr, '')
420+
regex = re.compile('\n'.join(pattern))
421+
self.assertTrue(regex.search(stdout), "Pattern '%s' found in: %s" % (regex.pattern, stdout))
422+
423+
# also test with command that is fed input via stdin
424+
self.mock_stdout(True)
425+
self.mock_stderr(True)
426+
res = run('cat', stdin='hello')
427+
stdout = self.get_stdout()
428+
stderr = self.get_stderr()
429+
self.mock_stdout(False)
430+
self.mock_stderr(False)
431+
self.assertEqual(res.output, 'hello')
432+
self.assertEqual(res.exit_code, 0)
433+
self.assertEqual(stderr, '')
434+
pattern.insert(3, r"\t\[input: hello\]")
435+
pattern[-2] = "\tcat"
436+
regex = re.compile('\n'.join(pattern))
437+
self.assertTrue(regex.search(stdout), "Pattern '%s' found in: %s" % (regex.pattern, stdout))
438+
439+
# trace output can be disabled on a per-command basis by enabling 'hidden'
440+
self.mock_stdout(True)
441+
self.mock_stderr(True)
442+
res = run("echo hello", hidden=True)
443+
stdout = self.get_stdout()
444+
stderr = self.get_stderr()
445+
self.mock_stdout(False)
446+
self.mock_stderr(False)
447+
self.assertEqual(res.output, 'hello\n')
448+
self.assertEqual(res.exit_code, 0)
333449
self.assertEqual(stdout, '')
334450
self.assertEqual(stderr, '')
335451

0 commit comments

Comments
 (0)