Skip to content

Commit c1bfff4

Browse files
gh-74696: Do not change the current working directory in shutil.make_archive() if possible (GH-93160) (GH-94105)
It is no longer changed when create a zip or tar archive. It is still changed for custom archivers registered with shutil.register_archive_format() if root_dir is not None. Co-authored-by: Éric <[email protected]> Co-authored-by: Łukasz Langa <[email protected]> (cherry picked from commit fda4b2f) Co-authored-by: Serhiy Storchaka <[email protected]>
1 parent 3a119d2 commit c1bfff4

File tree

4 files changed

+106
-52
lines changed

4 files changed

+106
-52
lines changed

Doc/library/shutil.rst

+7-1
Original file line numberDiff line numberDiff line change
@@ -574,12 +574,18 @@ provided. They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
574574

575575
.. note::
576576

577-
This function is not thread-safe.
577+
This function is not thread-safe when custom archivers registered
578+
with :func:`register_archive_format` are used. In this case it
579+
temporarily changes the current working directory of the process
580+
to perform archiving.
578581

579582
.. versionchanged:: 3.8
580583
The modern pax (POSIX.1-2001) format is now used instead of
581584
the legacy GNU format for archives created with ``format="tar"``.
582585

586+
.. versionchanged:: 3.10.6
587+
This function is now made thread-safe during creation of standard
588+
``.zip`` and tar archives.
583589

584590
.. function:: get_archive_formats()
585591

Lib/shutil.py

+65-34
Original file line numberDiff line numberDiff line change
@@ -896,7 +896,7 @@ def _get_uid(name):
896896
return None
897897

898898
def _make_tarball(base_name, base_dir, compress="gzip", verbose=0, dry_run=0,
899-
owner=None, group=None, logger=None):
899+
owner=None, group=None, logger=None, root_dir=None):
900900
"""Create a (possibly compressed) tar file from all the files under
901901
'base_dir'.
902902
@@ -953,14 +953,20 @@ def _set_uid_gid(tarinfo):
953953

954954
if not dry_run:
955955
tar = tarfile.open(archive_name, 'w|%s' % tar_compression)
956+
arcname = base_dir
957+
if root_dir is not None:
958+
base_dir = os.path.join(root_dir, base_dir)
956959
try:
957-
tar.add(base_dir, filter=_set_uid_gid)
960+
tar.add(base_dir, arcname, filter=_set_uid_gid)
958961
finally:
959962
tar.close()
960963

964+
if root_dir is not None:
965+
archive_name = os.path.abspath(archive_name)
961966
return archive_name
962967

963-
def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, logger=None):
968+
def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0,
969+
logger=None, owner=None, group=None, root_dir=None):
964970
"""Create a zip file from all the files under 'base_dir'.
965971
966972
The output zip file will be named 'base_name' + ".zip". Returns the
@@ -984,42 +990,60 @@ def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, logger=None):
984990
if not dry_run:
985991
with zipfile.ZipFile(zip_filename, "w",
986992
compression=zipfile.ZIP_DEFLATED) as zf:
987-
path = os.path.normpath(base_dir)
988-
if path != os.curdir:
989-
zf.write(path, path)
993+
arcname = os.path.normpath(base_dir)
994+
if root_dir is not None:
995+
base_dir = os.path.join(root_dir, base_dir)
996+
base_dir = os.path.normpath(base_dir)
997+
if arcname != os.curdir:
998+
zf.write(base_dir, arcname)
990999
if logger is not None:
991-
logger.info("adding '%s'", path)
1000+
logger.info("adding '%s'", base_dir)
9921001
for dirpath, dirnames, filenames in os.walk(base_dir):
1002+
arcdirpath = dirpath
1003+
if root_dir is not None:
1004+
arcdirpath = os.path.relpath(arcdirpath, root_dir)
1005+
arcdirpath = os.path.normpath(arcdirpath)
9931006
for name in sorted(dirnames):
994-
path = os.path.normpath(os.path.join(dirpath, name))
995-
zf.write(path, path)
1007+
path = os.path.join(dirpath, name)
1008+
arcname = os.path.join(arcdirpath, name)
1009+
zf.write(path, arcname)
9961010
if logger is not None:
9971011
logger.info("adding '%s'", path)
9981012
for name in filenames:
999-
path = os.path.normpath(os.path.join(dirpath, name))
1013+
path = os.path.join(dirpath, name)
1014+
path = os.path.normpath(path)
10001015
if os.path.isfile(path):
1001-
zf.write(path, path)
1016+
arcname = os.path.join(arcdirpath, name)
1017+
zf.write(path, arcname)
10021018
if logger is not None:
10031019
logger.info("adding '%s'", path)
10041020

1021+
if root_dir is not None:
1022+
zip_filename = os.path.abspath(zip_filename)
10051023
return zip_filename
10061024

1025+
# Maps the name of the archive format to a tuple containing:
1026+
# * the archiving function
1027+
# * extra keyword arguments
1028+
# * description
1029+
# * does it support the root_dir argument?
10071030
_ARCHIVE_FORMATS = {
1008-
'tar': (_make_tarball, [('compress', None)], "uncompressed tar file"),
1031+
'tar': (_make_tarball, [('compress', None)],
1032+
"uncompressed tar file", True),
10091033
}
10101034

10111035
if _ZLIB_SUPPORTED:
10121036
_ARCHIVE_FORMATS['gztar'] = (_make_tarball, [('compress', 'gzip')],
1013-
"gzip'ed tar-file")
1014-
_ARCHIVE_FORMATS['zip'] = (_make_zipfile, [], "ZIP file")
1037+
"gzip'ed tar-file", True)
1038+
_ARCHIVE_FORMATS['zip'] = (_make_zipfile, [], "ZIP file", True)
10151039

10161040
if _BZ2_SUPPORTED:
10171041
_ARCHIVE_FORMATS['bztar'] = (_make_tarball, [('compress', 'bzip2')],
1018-
"bzip2'ed tar-file")
1042+
"bzip2'ed tar-file", True)
10191043

10201044
if _LZMA_SUPPORTED:
10211045
_ARCHIVE_FORMATS['xztar'] = (_make_tarball, [('compress', 'xz')],
1022-
"xz'ed tar-file")
1046+
"xz'ed tar-file", True)
10231047

10241048
def get_archive_formats():
10251049
"""Returns a list of supported formats for archiving and unarchiving.
@@ -1050,7 +1074,7 @@ def register_archive_format(name, function, extra_args=None, description=''):
10501074
if not isinstance(element, (tuple, list)) or len(element) !=2:
10511075
raise TypeError('extra_args elements are : (arg_name, value)')
10521076

1053-
_ARCHIVE_FORMATS[name] = (function, extra_args, description)
1077+
_ARCHIVE_FORMATS[name] = (function, extra_args, description, False)
10541078

10551079
def unregister_archive_format(name):
10561080
del _ARCHIVE_FORMATS[name]
@@ -1074,36 +1098,38 @@ def make_archive(base_name, format, root_dir=None, base_dir=None, verbose=0,
10741098
uses the current owner and group.
10751099
"""
10761100
sys.audit("shutil.make_archive", base_name, format, root_dir, base_dir)
1077-
save_cwd = os.getcwd()
1078-
if root_dir is not None:
1079-
if logger is not None:
1080-
logger.debug("changing into '%s'", root_dir)
1081-
base_name = os.path.abspath(base_name)
1082-
if not dry_run:
1083-
os.chdir(root_dir)
1084-
1085-
if base_dir is None:
1086-
base_dir = os.curdir
1087-
1088-
kwargs = {'dry_run': dry_run, 'logger': logger}
1089-
10901101
try:
10911102
format_info = _ARCHIVE_FORMATS[format]
10921103
except KeyError:
10931104
raise ValueError("unknown archive format '%s'" % format) from None
10941105

1106+
kwargs = {'dry_run': dry_run, 'logger': logger,
1107+
'owner': owner, 'group': group}
1108+
10951109
func = format_info[0]
10961110
for arg, val in format_info[1]:
10971111
kwargs[arg] = val
10981112

1099-
if format != 'zip':
1100-
kwargs['owner'] = owner
1101-
kwargs['group'] = group
1113+
if base_dir is None:
1114+
base_dir = os.curdir
1115+
1116+
support_root_dir = format_info[3]
1117+
save_cwd = None
1118+
if root_dir is not None:
1119+
if support_root_dir:
1120+
kwargs['root_dir'] = root_dir
1121+
else:
1122+
save_cwd = os.getcwd()
1123+
if logger is not None:
1124+
logger.debug("changing into '%s'", root_dir)
1125+
base_name = os.path.abspath(base_name)
1126+
if not dry_run:
1127+
os.chdir(root_dir)
11021128

11031129
try:
11041130
filename = func(base_name, base_dir, **kwargs)
11051131
finally:
1106-
if root_dir is not None:
1132+
if save_cwd is not None:
11071133
if logger is not None:
11081134
logger.debug("changing back to '%s'", save_cwd)
11091135
os.chdir(save_cwd)
@@ -1216,6 +1242,11 @@ def _unpack_tarfile(filename, extract_dir):
12161242
finally:
12171243
tarobj.close()
12181244

1245+
# Maps the name of the unpack format to a tuple containing:
1246+
# * extensions
1247+
# * the unpacking function
1248+
# * extra keyword arguments
1249+
# * description
12191250
_UNPACK_FORMATS = {
12201251
'tar': (['.tar'], _unpack_tarfile, [], "uncompressed tar file"),
12211252
'zip': (['.zip'], _unpack_zipfile, [], "ZIP file"),

Lib/test/test_shutil.py

+32-17
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
except ImportError:
5252
_winapi = None
5353

54+
no_chdir = unittest.mock.patch('os.chdir',
55+
side_effect=AssertionError("shouldn't call os.chdir()"))
56+
5457
def _fake_rename(*args, **kwargs):
5558
# Pretend the destination path is on a different filesystem.
5659
raise OSError(getattr(errno, 'EXDEV', 18), "Invalid cross-device link")
@@ -1342,7 +1345,7 @@ def test_make_tarball(self):
13421345
work_dir = os.path.dirname(tmpdir2)
13431346
rel_base_name = os.path.join(os.path.basename(tmpdir2), 'archive')
13441347

1345-
with os_helper.change_cwd(work_dir):
1348+
with os_helper.change_cwd(work_dir), no_chdir:
13461349
base_name = os.path.abspath(rel_base_name)
13471350
tarball = make_archive(rel_base_name, 'gztar', root_dir, '.')
13481351

@@ -1356,7 +1359,7 @@ def test_make_tarball(self):
13561359
'./file1', './file2', './sub/file3'])
13571360

13581361
# trying an uncompressed one
1359-
with os_helper.change_cwd(work_dir):
1362+
with os_helper.change_cwd(work_dir), no_chdir:
13601363
tarball = make_archive(rel_base_name, 'tar', root_dir, '.')
13611364
self.assertEqual(tarball, base_name + '.tar')
13621365
self.assertTrue(os.path.isfile(tarball))
@@ -1392,7 +1395,8 @@ def _create_files(self, base_dir='dist'):
13921395
def test_tarfile_vs_tar(self):
13931396
root_dir, base_dir = self._create_files()
13941397
base_name = os.path.join(self.mkdtemp(), 'archive')
1395-
tarball = make_archive(base_name, 'gztar', root_dir, base_dir)
1398+
with no_chdir:
1399+
tarball = make_archive(base_name, 'gztar', root_dir, base_dir)
13961400

13971401
# check if the compressed tarball was created
13981402
self.assertEqual(tarball, base_name + '.tar.gz')
@@ -1409,13 +1413,15 @@ def test_tarfile_vs_tar(self):
14091413
self.assertEqual(self._tarinfo(tarball), self._tarinfo(tarball2))
14101414

14111415
# trying an uncompressed one
1412-
tarball = make_archive(base_name, 'tar', root_dir, base_dir)
1416+
with no_chdir:
1417+
tarball = make_archive(base_name, 'tar', root_dir, base_dir)
14131418
self.assertEqual(tarball, base_name + '.tar')
14141419
self.assertTrue(os.path.isfile(tarball))
14151420

14161421
# now for a dry_run
1417-
tarball = make_archive(base_name, 'tar', root_dir, base_dir,
1418-
dry_run=True)
1422+
with no_chdir:
1423+
tarball = make_archive(base_name, 'tar', root_dir, base_dir,
1424+
dry_run=True)
14191425
self.assertEqual(tarball, base_name + '.tar')
14201426
self.assertTrue(os.path.isfile(tarball))
14211427

@@ -1431,7 +1437,7 @@ def test_make_zipfile(self):
14311437
work_dir = os.path.dirname(tmpdir2)
14321438
rel_base_name = os.path.join(os.path.basename(tmpdir2), 'archive')
14331439

1434-
with os_helper.change_cwd(work_dir):
1440+
with os_helper.change_cwd(work_dir), no_chdir:
14351441
base_name = os.path.abspath(rel_base_name)
14361442
res = make_archive(rel_base_name, 'zip', root_dir)
14371443

@@ -1444,7 +1450,7 @@ def test_make_zipfile(self):
14441450
'dist/file1', 'dist/file2', 'dist/sub/file3',
14451451
'outer'])
14461452

1447-
with os_helper.change_cwd(work_dir):
1453+
with os_helper.change_cwd(work_dir), no_chdir:
14481454
base_name = os.path.abspath(rel_base_name)
14491455
res = make_archive(rel_base_name, 'zip', root_dir, base_dir)
14501456

@@ -1462,7 +1468,8 @@ def test_make_zipfile(self):
14621468
def test_zipfile_vs_zip(self):
14631469
root_dir, base_dir = self._create_files()
14641470
base_name = os.path.join(self.mkdtemp(), 'archive')
1465-
archive = make_archive(base_name, 'zip', root_dir, base_dir)
1471+
with no_chdir:
1472+
archive = make_archive(base_name, 'zip', root_dir, base_dir)
14661473

14671474
# check if ZIP file was created
14681475
self.assertEqual(archive, base_name + '.zip')
@@ -1488,7 +1495,8 @@ def test_zipfile_vs_zip(self):
14881495
def test_unzip_zipfile(self):
14891496
root_dir, base_dir = self._create_files()
14901497
base_name = os.path.join(self.mkdtemp(), 'archive')
1491-
archive = make_archive(base_name, 'zip', root_dir, base_dir)
1498+
with no_chdir:
1499+
archive = make_archive(base_name, 'zip', root_dir, base_dir)
14921500

14931501
# check if ZIP file was created
14941502
self.assertEqual(archive, base_name + '.zip')
@@ -1546,7 +1554,7 @@ def test_tarfile_root_owner(self):
15461554
base_name = os.path.join(self.mkdtemp(), 'archive')
15471555
group = grp.getgrgid(0)[0]
15481556
owner = pwd.getpwuid(0)[0]
1549-
with os_helper.change_cwd(root_dir):
1557+
with os_helper.change_cwd(root_dir), no_chdir:
15501558
archive_name = make_archive(base_name, 'gztar', root_dir, 'dist',
15511559
owner=owner, group=group)
15521560

@@ -1564,31 +1572,38 @@ def test_tarfile_root_owner(self):
15641572

15651573
def test_make_archive_cwd(self):
15661574
current_dir = os.getcwd()
1575+
root_dir = self.mkdtemp()
15671576
def _breaks(*args, **kw):
15681577
raise RuntimeError()
1578+
dirs = []
1579+
def _chdir(path):
1580+
dirs.append(path)
1581+
orig_chdir(path)
15691582

15701583
register_archive_format('xxx', _breaks, [], 'xxx file')
15711584
try:
1572-
try:
1573-
make_archive('xxx', 'xxx', root_dir=self.mkdtemp())
1574-
except Exception:
1575-
pass
1585+
with support.swap_attr(os, 'chdir', _chdir) as orig_chdir:
1586+
try:
1587+
make_archive('xxx', 'xxx', root_dir=root_dir)
1588+
except Exception:
1589+
pass
15761590
self.assertEqual(os.getcwd(), current_dir)
1591+
self.assertEqual(dirs, [root_dir, current_dir])
15771592
finally:
15781593
unregister_archive_format('xxx')
15791594

15801595
def test_make_tarfile_in_curdir(self):
15811596
# Issue #21280
15821597
root_dir = self.mkdtemp()
1583-
with os_helper.change_cwd(root_dir):
1598+
with os_helper.change_cwd(root_dir), no_chdir:
15841599
self.assertEqual(make_archive('test', 'tar'), 'test.tar')
15851600
self.assertTrue(os.path.isfile('test.tar'))
15861601

15871602
@support.requires_zlib()
15881603
def test_make_zipfile_in_curdir(self):
15891604
# Issue #21280
15901605
root_dir = self.mkdtemp()
1591-
with os_helper.change_cwd(root_dir):
1606+
with os_helper.change_cwd(root_dir), no_chdir:
15921607
self.assertEqual(make_archive('test', 'zip'), 'test.zip')
15931608
self.assertTrue(os.path.isfile('test.zip'))
15941609

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:func:`shutil.make_archive` no longer temporarily changes the current
2+
working directory during creation of standard ``.zip`` or tar archives.

0 commit comments

Comments
 (0)