Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create env.sh and cmd.sh helper scripts in run_shell_cmd #4486

Open
wants to merge 20 commits into
base: 5.0.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 49 additions & 10 deletions easybuild/tools/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import locale
import os
import re
import shlex
import shutil
import string
import subprocess
Expand Down Expand Up @@ -196,6 +197,42 @@ def fileprefix_from_cmd(cmd, allowed_chars=False):
return ''.join([c for c in cmd if c in allowed_chars])


def create_cmd_scripts(cmd_str, work_dir, env, tmpdir):
"""
Create helper scripts for specified command in specified directory:
- env.sh which can be sourced to define environment in which command was run;
- cmd.sh to create interactive (bash) shell session with working directory and environment,
and with the command in shell history;
"""
# Save environment variables in env.sh which can be sourced to restore environment
full_env = os.environ.copy()
if env is not None:
full_env.update(env)
env_fp = os.path.join(tmpdir, 'env.sh')
with open(env_fp, 'w') as fid:
# excludes bash functions (environment variables ending with %)
fid.write('\n'.join(f'export {key}={shlex.quote(value)}' for key, value in sorted(full_env.items())
if not key.endswith('%')))
fid.write('\n\nPS1="eb-shell> "')
# also change to working directory (to ensure that working directory is correct for interactive bash shell)
fid.write(f'\ncd "{work_dir}"')
fid.write(f'\nhistory -s {shlex.quote(cmd_str)}')

# Make script that sets up bash shell with specified environment and working directory
cmd_fp = os.path.join(tmpdir, 'cmd.sh')
with open(cmd_fp, 'w') as fid:
fid.write('#!/usr/bin/env bash\n')
fid.write('# Run this script to set up a shell environment that EasyBuild used to run the shell command\n')
fid.write('\n'.join([
'EB_SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )',
f'echo "# Shell for the command: {shlex.quote(cmd_str)}"',
'echo "# Use command history, exit to stop"',
# using -i to force interactive shell, so env.sh is also sourced when -c is used to run commands
'bash --rcfile $EB_SCRIPT_DIR/env.sh -i "$@"',
]))
os.chmod(cmd_fp, 0o775)


def _answer_question(stdout, proc, qa_patterns, qa_wait_patterns):
"""
Private helper function to try and answer questions raised in interactive shell commands.
Expand Down Expand Up @@ -329,12 +366,17 @@ def to_cmd_str(cmd):
_log.info(f"Auto-enabling streaming output of '{cmd_str}' command because logging to stdout is enabled")
stream_output = True

# temporary output file(s) for command output
# temporary output file(s) for command output, along with helper scripts
if output_file:
toptmpdir = os.path.join(tempfile.gettempdir(), 'run-shell-cmd-output')
os.makedirs(toptmpdir, exist_ok=True)
cmd_name = fileprefix_from_cmd(os.path.basename(cmd_str.split(' ')[0]))
tmpdir = tempfile.mkdtemp(dir=toptmpdir, prefix=f'{cmd_name}-')

_log.info(f'run_shell_cmd: command environment of "{cmd_str}" will be saved to {tmpdir}')

create_cmd_scripts(cmd_str, work_dir, env, tmpdir)

cmd_out_fp = os.path.join(tmpdir, 'out.txt')
_log.info(f'run_shell_cmd: Output of "{cmd_str}" will be logged to {cmd_out_fp}')
if split_stderr:
Expand All @@ -343,7 +385,7 @@ def to_cmd_str(cmd):
else:
cmd_err_fp = None
else:
cmd_out_fp, cmd_err_fp = None, None
tmpdir, cmd_out_fp, cmd_err_fp = None, None, None

interactive = bool(qa_patterns)
interactive_msg = 'interactive ' if interactive else ''
Expand All @@ -361,7 +403,7 @@ def to_cmd_str(cmd):

start_time = datetime.now()
if not hidden:
_cmd_trace_msg(cmd_str, start_time, work_dir, stdin, cmd_out_fp, cmd_err_fp, thread_id, interactive=interactive)
_cmd_trace_msg(cmd_str, start_time, work_dir, stdin, tmpdir, thread_id, interactive=interactive)

if stream_output:
print_msg(f"(streaming) output for command '{cmd_str}':")
Expand Down Expand Up @@ -522,16 +564,15 @@ def to_cmd_str(cmd):
return res


def _cmd_trace_msg(cmd, start_time, work_dir, stdin, cmd_out_fp, cmd_err_fp, thread_id, interactive=False):
def _cmd_trace_msg(cmd, start_time, work_dir, stdin, tmpdir, thread_id, interactive=False):
"""
Helper function to construct and print trace message for command being run

:param cmd: command being run
:param start_time: datetime object indicating when command was started
:param work_dir: path of working directory in which command is run
:param stdin: stdin input value for command
:param cmd_out_fp: path to output file for command
:param cmd_err_fp: path to errors/warnings output file for command
:param tmpdir: path to temporary output directory for command
:param thread_id: thread ID (None when not running shell command asynchronously)
:param interactive: boolean indicating whether it is an interactive command, or not
"""
Expand All @@ -551,10 +592,8 @@ def _cmd_trace_msg(cmd, start_time, work_dir, stdin, cmd_out_fp, cmd_err_fp, thr
]
if stdin:
lines.append(f"\t[input: {stdin}]")
if cmd_out_fp:
lines.append(f"\t[output saved to {cmd_out_fp}]")
if cmd_err_fp:
lines.append(f"\t[errors/warnings saved to {cmd_err_fp}]")
if tmpdir:
lines.append(f"\t[output and state saved to {tmpdir}]")

trace_msg('\n'.join(lines))

Expand Down
46 changes: 43 additions & 3 deletions test/framework/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ def test_run_cmd(self):
def test_run_shell_cmd_basic(self):
"""Basic test for run_shell_cmd function."""

os.environ['FOOBAR'] = 'foobar'

cwd = change_dir(self.test_prefix)

with self.mocked_stdout_stderr():
res = run_shell_cmd("echo hello")
self.assertEqual(res.output, "hello\n")
Expand All @@ -189,6 +193,42 @@ def test_run_shell_cmd_basic(self):
self.assertEqual(res.stderr, None)
self.assertTrue(res.work_dir and isinstance(res.work_dir, str))

change_dir(cwd)
del os.environ['FOOBAR']

# check on helper scripts that were generated for this command
paths = glob.glob(os.path.join(self.test_prefix, 'eb-*', 'run-shell-cmd-output', 'echo-*'))
self.assertEqual(len(paths), 1)
cmd_tmpdir = paths[0]

# check on env.sh script that can be used to set up environment in which command was run
env_script = os.path.join(cmd_tmpdir, 'env.sh')
self.assertExists(env_script)
env_script_txt = read_file(env_script)
self.assertIn("export FOOBAR=foobar", env_script_txt)
self.assertIn("history -s 'echo hello'", env_script_txt)

with self.mocked_stdout_stderr():
res = run_shell_cmd(f"source {env_script}; echo $FOOBAR; history")
self.assertEqual(res.exit_code, 0)
self.assertTrue(res.output.startswith('foobar\n'))
self.assertTrue(res.output.endswith("echo hello\n"))

# check on cmd.sh script that can be used to create interactive shell environment for command
cmd_script = os.path.join(cmd_tmpdir, 'cmd.sh')
self.assertExists(cmd_script)

with self.mocked_stdout_stderr():
res = run_shell_cmd(f"{cmd_script} -c 'echo pwd: $PWD; echo $FOOBAR'", fail_on_error=False)
self.assertEqual(res.exit_code, 0)
self.assertTrue(res.output.endswith('foobar\n'))
# check whether working directory is what's expected
regex = re.compile('^pwd: .*', re.M)
res = regex.findall(res.output)
self.assertEqual(len(res), 1)
pwd = res[0].strip()[5:]
self.assertTrue(os.path.samefile(pwd, self.test_prefix))

# test running command that emits non-UTF-8 characters
# this is constructed to reproduce errors like:
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2
Expand Down Expand Up @@ -676,7 +716,7 @@ def test_run_shell_cmd_trace(self):
r"\techo hello",
r"\t\[started at: .*\]",
r"\t\[working dir: .*\]",
r"\t\[output saved to .*\]",
r"\t\[output and state saved to .*\]",
r" >> command completed: exit 0, ran in .*",
]

Expand Down Expand Up @@ -736,7 +776,7 @@ def test_run_shell_cmd_trace_stdin(self):
r"\techo hello",
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]\]",
r"\t\[working dir: .*\]",
r"\t\[output saved to .*\]",
r"\t\[output and state saved to .*\]",
r" >> command completed: exit 0, ran in .*",
]

Expand Down Expand Up @@ -1092,7 +1132,7 @@ def test_run_shell_cmd_qa_trace(self):
pattern += r"\techo \'n: \'; read n; seq 1 \$n\n"
pattern += r"\t\[started at: .*\]\n"
pattern += r"\t\[working dir: .*\]\n"
pattern += r"\t\[output saved to .*\]\n"
pattern += r"\t\[output and state saved to .*\]\n"
pattern += r' >> command completed: exit 0, ran in .*'
self.assertTrue(re.search(pattern, stdout), "Pattern '%s' found in: %s" % (pattern, stdout))

Expand Down
2 changes: 1 addition & 1 deletion test/framework/toy_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -2985,7 +2985,7 @@ def test_toy_build_trace(self):
r"\tgcc toy.c -o toy\n"
r"\t\[started at: .*\]",
r"\t\[working dir: .*\]",
r"\t\[output saved to .*\]",
r"\t\[output and state saved to .*\]",
r'',
]),
r" >> command completed: exit 0, ran in .*",
Expand Down