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

implement support in 'run' function for running command in different working directory + switch to run function in filetools #4327

Merged
merged 9 commits into from
Aug 21, 2023
122 changes: 56 additions & 66 deletions easybuild/tools/filetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@
import urllib.request as std_urllib

from easybuild.base import fancylogger
from easybuild.tools import run
# import build_log must stay, to use of EasyBuildLog
from easybuild.tools.build_log import EasyBuildError, dry_run_msg, print_msg, print_warning
from easybuild.tools.config import ERROR, GENERIC_EASYBLOCK_PKG, IGNORE, WARN, build_option, install_path
from easybuild.tools.output import PROGRESS_BAR_DOWNLOAD_ONE, start_progress_bar, stop_progress_bar, update_progress_bar
from easybuild.tools.run import run
from easybuild.tools.utilities import natural_keys, nub, remove_unwanted_chars, trace_msg

try:
Expand Down Expand Up @@ -445,32 +445,32 @@ def extract_file(fn, dest, cmd=None, extra_options=None, overwrite=False, forced
"""

if not os.path.isfile(fn) and not build_option('extended_dry_run'):
raise EasyBuildError("Can't extract file %s: no such file", fn)
raise EasyBuildError(f"Can't extract file {fn}: no such file")

mkdir(dest, parents=True)

# use absolute pathnames from now on
abs_dest = os.path.abspath(dest)

# change working directory
_log.debug("Unpacking %s in directory %s", fn, abs_dest)
_log.debug(f"Unpacking {fn} in directory {abs_dest}")
cwd = change_dir(abs_dest)

if cmd:
# complete command template with filename
cmd = cmd % fn
_log.debug("Using specified command to unpack %s: %s", fn, cmd)
_log.debug("Using specified command to unpack {fn}: {cmd}")
else:
cmd = extract_cmd(fn, overwrite=overwrite)
_log.debug("Using command derived from file extension to unpack %s: %s", fn, cmd)
_log.debug("Using command derived from file extension to unpack {fn}: {cmd}")

if not cmd:
raise EasyBuildError("Can't extract file %s with unknown filetype", fn)
raise EasyBuildError("Can't extract file {fn} with unknown filetype")

if extra_options:
cmd = "%s %s" % (cmd, extra_options)
cmd = f"{cmd} {extra_options}"

run.run_cmd(cmd, simple=True, force_in_dry_run=forced, trace=trace)
run(cmd, in_dry_run=forced, hidden=not trace)

# note: find_base_dir also changes into the base dir!
base_dir = find_base_dir()
Expand All @@ -479,7 +479,7 @@ def extract_file(fn, dest, cmd=None, extra_options=None, overwrite=False, forced
# change back to where we came from (unless that was a non-existing directory)
if not change_into_dir:
if cwd is None:
raise EasyBuildError("Can't change back to non-existing directory after extracting %s in %s", fn, dest)
raise EasyBuildError(f"Can't change back to non-existing directory after extracting {fn} in {dest}")
else:
change_dir(cwd)

Expand Down Expand Up @@ -1547,27 +1547,27 @@ def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False
if build_option('extended_dry_run'):
# skip checking of files in dry run mode
patch_filename = os.path.basename(patch_file)
dry_run_msg("* applying patch file %s" % patch_filename, silent=build_option('silent'))
dry_run_msg(f"* applying patch file {patch_filename}", silent=build_option('silent'))

elif not os.path.isfile(patch_file):
raise EasyBuildError("Can't find patch %s: no such file", patch_file)
raise EasyBuildError(f"Can't find patch {patch_file}: no such file")

elif fn and not os.path.isfile(fn):
raise EasyBuildError("Can't patch file %s: no such file", fn)
raise EasyBuildError(f"Can't patch file {fn}: no such file")

# copy missing files
if copy:
if build_option('extended_dry_run'):
dry_run_msg(" %s copied to %s" % (patch_file, dest), silent=build_option('silent'))
dry_run_msg(f" {patch_file} copied to {dest}", silent=build_option('silent'))
else:
copy_file(patch_file, dest)
_log.debug("Copied patch %s to dir %s" % (patch_file, dest))
_log.debug(f"Copied patch {patch_file} to dir {dest}")

# early exit, work is done after copying
return True

elif not os.path.isdir(dest):
raise EasyBuildError("Can't patch directory %s: no such directory", dest)
raise EasyBuildError(f"Can't patch directory {dest}: no such directory")

# use absolute paths
abs_patch_file = os.path.abspath(patch_file)
Expand All @@ -1582,14 +1582,14 @@ def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False
patch_subextension = os.path.splitext(patch_stem)[1]
if patch_subextension == ".patch":
workdir = tempfile.mkdtemp(prefix='eb-patch-')
_log.debug("Extracting the patch to: %s", workdir)
_log.debug(f"Extracting the patch to: {workdir}")
# extracting the patch
extracted_dir = extract_file(abs_patch_file, workdir, change_into_dir=False)
abs_patch_file = os.path.join(extracted_dir, patch_stem)

if use_git:
verbose = '--verbose ' if build_option('debug') else ''
patch_cmd = "git apply %s%s" % (verbose, abs_patch_file)
patch_cmd = f"git apply {verbose}{abs_patch_file}"
else:
if level is None and build_option('extended_dry_run'):
level = '<derived>'
Expand All @@ -1602,27 +1602,30 @@ def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False
patched_files = det_patched_files(path=abs_patch_file)

if not patched_files:
raise EasyBuildError("Can't guess patchlevel from patch %s: no testfile line found in patch",
abs_patch_file)
msg = f"Can't guess patchlevel from patch {abs_patch_file}: no testfile line found in patch"
raise EasyBuildError(msg)

level = guess_patch_level(patched_files, abs_dest)

if level is None: # level can also be 0 (zero), so don't use "not level"
# no match
raise EasyBuildError("Can't determine patch level for patch %s from directory %s", patch_file, abs_dest)
raise EasyBuildError(f"Can't determine patch level for patch {patch_file} from directory {abs_dest}")
else:
_log.debug("Guessed patch level %d for patch %s" % (level, patch_file))
_log.debug(f"Guessed patch level {level} for patch {patch_file}")

else:
_log.debug("Using specified patch level %d for patch %s" % (level, patch_file))
_log.debug(f"Using specified patch level {level} for patch {patch_file}")

backup_option = '-b ' if build_option('backup_patched_files') else ''
patch_cmd = "patch " + backup_option + "-p%s -i %s" % (level, abs_patch_file)
patch_cmd = f"patch {backup_option} -p{level} -i {abs_patch_file}"

out, ec = run.run_cmd(patch_cmd, simple=False, path=abs_dest, log_ok=False, trace=False)
res = run(patch_cmd, fail_on_error=False, hidden=True, work_dir=abs_dest)

if res.exit_code:
msg = f"Couldn't apply patch file {patch_file}. "
msg += f"Process exited with code {res.exit_code}: {res.output}"
raise EasyBuildError(msg)

if ec:
raise EasyBuildError("Couldn't apply patch file %s. Process exited with code %s: %s", patch_file, ec, out)
return True


Expand Down Expand Up @@ -2148,7 +2151,7 @@ def move_logs(src_logfile, target_logfile):
try:

# there may be multiple log files, due to log rotation
app_logs = glob.glob('%s*' % src_logfile)
app_logs = glob.glob(f'{src_logfile}*')
for app_log in app_logs:
# retain possible suffix
new_log_path = target_logfile + app_log[src_logfile_len:]
Expand All @@ -2159,11 +2162,11 @@ def move_logs(src_logfile, target_logfile):

# move log to target path
move_file(app_log, new_log_path)
_log.info("Moved log file %s to %s" % (src_logfile, new_log_path))
_log.info(f"Moved log file {src_logfile} to {new_log_path}")

if zip_log_cmd:
run.run_cmd("%s %s" % (zip_log_cmd, new_log_path))
_log.info("Zipped log %s using '%s'", new_log_path, zip_log_cmd)
run(f"{zip_log_cmd} {new_log_path}")
_log.info(f"Zipped log {new_log_path} using '{zip_log_cmd}'")

except (IOError, OSError) as err:
raise EasyBuildError("Failed to move log file(s) %s* to new log file %s*: %s",
Expand Down Expand Up @@ -2253,21 +2256,6 @@ def decode_class_name(name):
return decode_string(name)


def run_cmd(cmd, log_ok=True, log_all=False, simple=False, inp=None, regexp=True, log_output=False, path=None):
"""NO LONGER SUPPORTED: use run_cmd from easybuild.tools.run instead"""
_log.nosupport("run_cmd was moved from easybuild.tools.filetools to easybuild.tools.run", '2.0')


def run_cmd_qa(cmd, qa, no_qa=None, log_ok=True, log_all=False, simple=False, regexp=True, std_qa=None, path=None):
"""NO LONGER SUPPORTED: use run_cmd_qa from easybuild.tools.run instead"""
_log.nosupport("run_cmd_qa was moved from easybuild.tools.filetools to easybuild.tools.run", '2.0')


def parse_log_for_error(txt, regExp=None, stdout=True, msg=None):
"""NO LONGER SUPPORTED: use parse_log_for_error from easybuild.tools.run instead"""
_log.nosupport("parse_log_for_error was moved from easybuild.tools.filetools to easybuild.tools.run", '2.0')


def det_size(path):
"""
Determine total size of given filepath (in bytes).
Expand Down Expand Up @@ -2592,7 +2580,7 @@ def get_source_tarball_from_git(filename, targetdir, git_config):
"""
# sanity check on git_config value being passed
if not isinstance(git_config, dict):
raise EasyBuildError("Found unexpected type of value for 'git_config' argument: %s" % type(git_config))
raise EasyBuildError("Found unexpected type of value for 'git_config' argument: {type(git_config)}")

# Making a copy to avoid modifying the object with pops
git_config = git_config.copy()
Expand All @@ -2606,7 +2594,7 @@ def get_source_tarball_from_git(filename, targetdir, git_config):

# input validation of git_config dict
if git_config:
raise EasyBuildError("Found one or more unexpected keys in 'git_config' specification: %s", git_config)
raise EasyBuildError("Found one or more unexpected keys in 'git_config' specification: {git_config}")

if not repo_name:
raise EasyBuildError("repo_name not specified in git_config parameter")
Expand Down Expand Up @@ -2643,14 +2631,14 @@ def get_source_tarball_from_git(filename, targetdir, git_config):
# checkout is done separately below for specific commits
clone_cmd.append('--no-checkout')

clone_cmd.append('%s/%s.git' % (url, repo_name))
clone_cmd.append(f'{url}/{repo_name}.git')

if clone_into:
clone_cmd.append('%s' % clone_into)
clone_cmd.append(clone_into)

tmpdir = tempfile.mkdtemp()
cwd = change_dir(tmpdir)
run.run_cmd(' '.join(clone_cmd), log_all=True, simple=True, regexp=False, trace=False)

run(' '.join(clone_cmd), hidden=True, verbose_dry_run=True, work_dir=tmpdir)

# If the clone is done into a specified name, change repo_name
if clone_into:
Expand All @@ -2662,43 +2650,45 @@ def get_source_tarball_from_git(filename, targetdir, git_config):
if recursive:
checkout_cmd.extend(['&&', 'git', 'submodule', 'update', '--init', '--recursive'])

run.run_cmd(' '.join(checkout_cmd), log_all=True, simple=True, regexp=False, path=repo_name, trace=False)
work_dir = os.path.join(tmpdir, repo_name) if repo_name else tmpdir
run(' '.join(checkout_cmd), work_dir=work_dir, hidden=True, verbose_dry_run=True)

elif not build_option('extended_dry_run'):
# If we wanted to get a tag make sure we actually got a tag and not a branch with the same name
# This doesn't make sense in dry-run mode as we don't have anything to check
cmd = 'git describe --exact-match --tags HEAD'
# Note: Disable logging to also disable the error handling in run_cmd
(out, ec) = run.run_cmd(cmd, log_ok=False, log_all=False, regexp=False, path=repo_name, trace=False)
if ec != 0 or tag not in out.splitlines():
print_warning('Tag %s was not downloaded in the first try due to %s/%s containing a branch'
' with the same name. You might want to alert the maintainers of %s about that issue.',
tag, url, repo_name, repo_name)
cmd = "git describe --exact-match --tags HEAD"
work_dir = os.path.join(tmpdir, repo_name) if repo_name else tmpdir
res = run(cmd, fail_on_error=False, work_dir=work_dir, hidden=True, verbose_dry_run=True)

if res.exit_code != 0 or tag not in res.output.splitlines():
msg = f"Tag {tag} was not downloaded in the first try due to {url}/{repo_name} containing a branch"
msg += f" with the same name. You might want to alert the maintainers of {repo_name} about that issue."
print_warning(msg)

cmds = []

if not keep_git_dir:
# make the repo unshallow first;
# this is equivalent with 'git fetch -unshallow' in Git 1.8.3+
# (first fetch seems to do nothing, unclear why)
cmds.append('git fetch --depth=2147483647 && git fetch --depth=2147483647')
cmds.append("git fetch --depth=2147483647 && git fetch --depth=2147483647")

cmds.append('git checkout refs/tags/' + tag)
cmds.append(f"git checkout refs/tags/{tag}")
# Clean all untracked files, e.g. from left-over submodules
cmds.append('git clean --force -d -x')
cmds.append("git clean --force -d -x")
if recursive:
cmds.append('git submodule update --init --recursive')
cmds.append("git submodule update --init --recursive")
for cmd in cmds:
run.run_cmd(cmd, log_all=True, simple=True, regexp=False, path=repo_name, trace=False)
run(cmd, work_dir=work_dir, hidden=True, verbose_dry_run=True)

# create an archive and delete the git repo directory
if keep_git_dir:
tar_cmd = ['tar', 'cfvz', targetpath, repo_name]
else:
tar_cmd = ['tar', 'cfvz', targetpath, '--exclude', '.git', repo_name]
run.run_cmd(' '.join(tar_cmd), log_all=True, simple=True, regexp=False, trace=False)
run(' '.join(tar_cmd), work_dir=tmpdir, hidden=True, verbose_dry_run=True)

# cleanup (repo_name dir does not exist in dry run mode)
change_dir(cwd)
remove(tmpdir)

return targetpath
Expand Down
9 changes: 5 additions & 4 deletions easybuild/tools/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def cache_aware_func(cmd, *args, **kwargs):

@run_cache
def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,
hidden=False, in_dry_run=False, work_dir=None, shell=True,
hidden=False, in_dry_run=False, verbose_dry_run=False, work_dir=None, shell=True,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having both hidden (to disable output in both trace and dry run mode) and verbose_dry_run is necessary evil, since there are situations where we do not want trace output, but we do want to see commands that would be executed in dry run mode (primarily in get_source_tarball_from_git)

output_file=False, stream_output=False, asynchronous=False,
qa_patterns=None, qa_wait_patterns=None):
"""
Expand All @@ -120,6 +120,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,
:param env: environment to use to run command (if None, inherit current process environment)
:param hidden: do not show command in terminal output (when using --trace, or with --extended-dry-run / -x)
:param in_dry_run: also run command in dry run mode
:param verbose_dry_run: show that command is run in dry run mode (overrules 'hidden')
:param work_dir: working directory to run command in (current working directory if None)
:param shell: execute command through bash shell (enabled by default)
:param output_file: collect command output in temporary output file
Expand All @@ -135,7 +136,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,
"""

# temporarily raise a NotImplementedError until all options are implemented
if any((work_dir, stream_output, asynchronous)):
if any((stream_output, asynchronous)):
raise NotImplementedError

if qa_patterns or qa_wait_patterns:
Expand All @@ -162,7 +163,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,

# early exit in 'dry run' mode, after printing the command that would be run (unless 'hidden' is enabled)
if not in_dry_run and build_option('extended_dry_run'):
if not hidden:
if not hidden or verbose_dry_run:
silent = build_option('silent')
msg = f" running command \"{cmd_str}\"\n"
msg += f" (in {work_dir})"
Expand All @@ -187,7 +188,7 @@ def run(cmd, fail_on_error=True, split_stderr=False, stdin=None, env=None,

_log.info(f"Running command '{cmd_str}' in {work_dir}")
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=stderr, check=fail_on_error,
env=env, input=stdin, shell=shell, executable=executable)
cwd=work_dir, env=env, input=stdin, shell=shell, executable=executable)

# return output as a regular string rather than a byte sequence (and non-UTF-8 characters get stripped out)
output = proc.stdout.decode('utf-8', 'ignore')
Expand Down
4 changes: 2 additions & 2 deletions test/framework/filetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2831,7 +2831,7 @@ def run_check():
r' running command "git clone --no-checkout %(git_repo)s"',
r" \(in /.*\)",
r' running command "git checkout 8456f86 && git submodule update --init --recursive"',
r" \(in testrepository\)",
r" \(in /.*/testrepository\)",
r' running command "tar cfvz .*/target/test.tar.gz --exclude .git testrepository"',
r" \(in /.*\)",
]) % git_repo
Expand All @@ -2842,7 +2842,7 @@ def run_check():
r' running command "git clone --no-checkout %(git_repo)s"',
r" \(in /.*\)",
r' running command "git checkout 8456f86"',
r" \(in testrepository\)",
r" \(in /.*/testrepository\)",
r' running command "tar cfvz .*/target/test.tar.gz --exclude .git testrepository"',
r" \(in /.*\)",
]) % git_repo
Expand Down