Skip to content

Commit 42aaf63

Browse files
authored
Merge pull request #3310 from boegel/no_distutils_dir_utils
don't use distutils.dir_util in copy_dir
2 parents be2e192 + 768129e commit 42aaf63

File tree

2 files changed

+75
-39
lines changed

2 files changed

+75
-39
lines changed

easybuild/tools/filetools.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
"""
4141
import datetime
4242
import difflib
43-
import distutils.dir_util
4443
import fileinput
4544
import glob
4645
import hashlib
@@ -1925,7 +1924,12 @@ def copy_file(path, target_path, force_in_dry_run=False):
19251924
_log.info("Copied contents of file %s to %s", path, target_path)
19261925
else:
19271926
mkdir(os.path.dirname(target_path), parents=True)
1928-
shutil.copy2(path, target_path)
1927+
if os.path.exists(path):
1928+
shutil.copy2(path, target_path)
1929+
elif os.path.islink(path):
1930+
# special care for copying broken symlinks
1931+
link_target = os.readlink(path)
1932+
symlink(link_target, target_path)
19291933
_log.info("%s copied to %s", path, target_path)
19301934
except (IOError, OSError, shutil.Error) as err:
19311935
raise EasyBuildError("Failed to copy file %s to %s: %s", path, target_path, err)
@@ -1960,16 +1964,13 @@ def copy_dir(path, target_path, force_in_dry_run=False, dirs_exist_ok=False, **k
19601964
:param path: the original directory path
19611965
:param target_path: path to copy the directory to
19621966
:param force_in_dry_run: force running the command during dry run
1963-
:param dirs_exist_ok: wrapper around shutil.copytree option, which was added in Python 3.8
1967+
:param dirs_exist_ok: boolean indicating whether it's OK if the target directory already exists
19641968
1965-
On Python >= 3.8 shutil.copytree is always used
1966-
On Python < 3.8 if 'dirs_exist_ok' is False - shutil.copytree is used
1967-
On Python < 3.8 if 'dirs_exist_ok' is True - distutils.dir_util.copy_tree is used
1969+
shutil.copytree is used if the target path does not exist yet;
1970+
if the target path already exists, the 'copy' function will be used to copy the contents of
1971+
the source path to the target path
19681972
1969-
Additional specified named arguments are passed down to shutil.copytree if used.
1970-
1971-
Because distutils.dir_util.copy_tree supports only 'symlinks' named argument,
1972-
using any other will raise EasyBuildError.
1973+
Additional specified named arguments are passed down to shutil.copytree/copy if used.
19731974
"""
19741975
if not force_in_dry_run and build_option('extended_dry_run'):
19751976
dry_run_msg("copied directory %s to %s" % (path, target_path))
@@ -1978,38 +1979,49 @@ def copy_dir(path, target_path, force_in_dry_run=False, dirs_exist_ok=False, **k
19781979
if not dirs_exist_ok and os.path.exists(target_path):
19791980
raise EasyBuildError("Target location %s to copy %s to already exists", target_path, path)
19801981

1981-
if sys.version_info >= (3, 8):
1982-
# on Python >= 3.8, shutil.copytree works fine, thanks to availability of dirs_exist_ok named argument
1983-
shutil.copytree(path, target_path, dirs_exist_ok=dirs_exist_ok, **kwargs)
1982+
# note: in Python >= 3.8 shutil.copytree works just fine thanks to the 'dirs_exist_ok' argument,
1983+
# but since we need to be more careful in earlier Python versions we use our own implementation
1984+
# in case the target directory exists and 'dirs_exist_ok' is enabled
1985+
if dirs_exist_ok and os.path.exists(target_path):
1986+
# if target directory already exists (and that's allowed via dirs_exist_ok),
1987+
# we need to be more careful, since shutil.copytree will fail (in Python < 3.8)
1988+
# if target directory already exists;
1989+
# so, recurse via 'copy' function to copy files/dirs in source path to target path
1990+
# (NOTE: don't use distutils.dir_util.copy_tree here, see
1991+
# https://github.com/easybuilders/easybuild-framework/issues/3306)
1992+
1993+
entries = os.listdir(path)
19841994

1985-
elif dirs_exist_ok:
1986-
# use distutils.dir_util.copy_tree with Python < 3.8 if dirs_exist_ok is enabled
1995+
# take into account 'ignore' function that is supported by shutil.copytree
1996+
# (but not by 'copy_file' function used by 'copy')
1997+
ignore = kwargs.get('ignore')
1998+
if ignore:
1999+
ignored_entries = ignore(path, entries)
2000+
entries = [x for x in entries if x not in ignored_entries]
19872001

1988-
# first get value for symlinks named argument (if any)
1989-
preserve_symlinks = kwargs.pop('symlinks', False)
2002+
# determine list of paths to copy
2003+
paths_to_copy = [os.path.join(path, x) for x in entries]
19902004

1991-
# check if there are other named arguments (there shouldn't be, only 'symlinks' is supported)
1992-
if kwargs:
1993-
raise EasyBuildError("Unknown named arguments passed to copy_dir with dirs_exist_ok=True: %s",
1994-
', '.join(sorted(kwargs.keys())))
1995-
distutils.dir_util.copy_tree(path, target_path, preserve_symlinks=preserve_symlinks)
2005+
copy(paths_to_copy, target_path,
2006+
force_in_dry_run=force_in_dry_run, dirs_exist_ok=dirs_exist_ok, **kwargs)
19962007

19972008
else:
1998-
# if dirs_exist_ok is not enabled, just use shutil.copytree
2009+
# if dirs_exist_ok is not enabled or target directory doesn't exist, just use shutil.copytree
19992010
shutil.copytree(path, target_path, **kwargs)
20002011

20012012
_log.info("%s copied to %s", path, target_path)
2002-
except (IOError, OSError) as err:
2013+
except (IOError, OSError, shutil.Error) as err:
20032014
raise EasyBuildError("Failed to copy directory %s to %s: %s", path, target_path, err)
20042015

20052016

2006-
def copy(paths, target_path, force_in_dry_run=False):
2017+
def copy(paths, target_path, force_in_dry_run=False, **kwargs):
20072018
"""
20082019
Copy single file/directory or list of files and directories to specified location
20092020
20102021
:param paths: path(s) to copy
20112022
:param target_path: target location
20122023
:param force_in_dry_run: force running the command during dry run
2024+
:param kwargs: additional named arguments to pass down to copy_dir
20132025
"""
20142026
if isinstance(paths, string_type):
20152027
paths = [paths]
@@ -2020,10 +2032,11 @@ def copy(paths, target_path, force_in_dry_run=False):
20202032
full_target_path = os.path.join(target_path, os.path.basename(path))
20212033
mkdir(os.path.dirname(full_target_path), parents=True)
20222034

2023-
if os.path.isfile(path):
2035+
# copy broken symlinks only if 'symlinks=True' is used
2036+
if os.path.isfile(path) or (os.path.islink(path) and kwargs.get('symlinks')):
20242037
copy_file(path, full_target_path, force_in_dry_run=force_in_dry_run)
20252038
elif os.path.isdir(path):
2026-
copy_dir(path, full_target_path, force_in_dry_run=force_in_dry_run)
2039+
copy_dir(path, full_target_path, force_in_dry_run=force_in_dry_run, **kwargs)
20272040
else:
20282041
raise EasyBuildError("Specified path to copy is not an existing file or directory: %s", path)
20292042

test/framework/filetools.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,22 +1508,45 @@ def test_copy_dir(self):
15081508
ft.copy_dir(to_copy, testdir, dirs_exist_ok=True)
15091509
self.assertTrue(sorted(os.listdir(to_copy)) == sorted(os.listdir(testdir)))
15101510

1511-
# if the directory already exists and 'dirs_exist_ok' is True and there is another named argument (ignore)
1512-
# we expect clean error on Python < 3.8 and pass the test on Python >= 3.8
1513-
# NOTE: reused ignore from previous test
1511+
# check whether use of 'ignore' works if target path already exists and 'dirs_exist_ok' is enabled
15141512
def ignore_func(_, names):
15151513
return [x for x in names if '6.4.0-2.28' in x]
15161514

15171515
shutil.rmtree(testdir)
15181516
ft.mkdir(testdir)
1519-
if sys.version_info >= (3, 8):
1520-
ft.copy_dir(to_copy, testdir, dirs_exist_ok=True, ignore=ignore_func)
1521-
self.assertEqual(sorted(os.listdir(testdir)), expected)
1522-
self.assertFalse(os.path.exists(os.path.join(testdir, 'GCC-6.4.0-2.28.eb')))
1523-
else:
1524-
error_pattern = "Unknown named arguments passed to copy_dir with dirs_exist_ok=True: ignore"
1525-
self.assertErrorRegex(EasyBuildError, error_pattern, ft.copy_dir, to_copy, testdir,
1526-
dirs_exist_ok=True, ignore=ignore_func)
1517+
ft.copy_dir(to_copy, testdir, dirs_exist_ok=True, ignore=ignore_func)
1518+
self.assertEqual(sorted(os.listdir(testdir)), expected)
1519+
self.assertFalse(os.path.exists(os.path.join(testdir, 'GCC-6.4.0-2.28.eb')))
1520+
1521+
# test copy_dir when broken symlinks are involved
1522+
srcdir = os.path.join(self.test_prefix, 'topdir_to_copy')
1523+
ft.mkdir(srcdir)
1524+
ft.write_file(os.path.join(srcdir, 'test.txt'), '123')
1525+
subdir = os.path.join(srcdir, 'subdir')
1526+
# introduce broken file symlink
1527+
foo_txt = os.path.join(subdir, 'foo.txt')
1528+
ft.write_file(foo_txt, 'bar')
1529+
ft.symlink(foo_txt, os.path.join(subdir, 'bar.txt'))
1530+
ft.remove_file(foo_txt)
1531+
# introduce broken dir symlink
1532+
subdir_tmp = os.path.join(srcdir, 'subdir_tmp')
1533+
ft.mkdir(subdir_tmp)
1534+
ft.symlink(subdir_tmp, os.path.join(srcdir, 'subdir_link'))
1535+
ft.remove_dir(subdir_tmp)
1536+
1537+
target_dir = os.path.join(self.test_prefix, 'target_to_copy_to')
1538+
1539+
# trying this without symlinks=True ends in tears, because bar.txt points to a non-existing file
1540+
self.assertErrorRegex(EasyBuildError, "Failed to copy directory", ft.copy_dir, srcdir, target_dir)
1541+
ft.remove_dir(target_dir)
1542+
1543+
ft.copy_dir(srcdir, target_dir, symlinks=True)
1544+
1545+
# copying directory with broken symlinks should also work if target directory already exists
1546+
ft.remove_dir(target_dir)
1547+
ft.mkdir(target_dir)
1548+
ft.mkdir(subdir)
1549+
ft.copy_dir(srcdir, target_dir, symlinks=True, dirs_exist_ok=True)
15271550

15281551
# also test behaviour of copy_file under --dry-run
15291552
build_options = {
@@ -1542,7 +1565,7 @@ def ignore_func(_, names):
15421565
self.mock_stdout(False)
15431566

15441567
self.assertFalse(os.path.exists(target_dir))
1545-
self.assertTrue(re.search("^copied directory .*/GCC to .*/GCC", txt))
1568+
self.assertTrue(re.search("^copied directory .*/GCC to .*/%s" % os.path.basename(target_dir), txt))
15461569

15471570
# forced copy, even in dry run mode
15481571
self.mock_stdout(True)

0 commit comments

Comments
 (0)