Skip to content

Commit e51a22b

Browse files
gh-74696: Do not change the current working directory in shutil.make_archive() if possible (GH-93160) (GH-94106)
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 <merwok@netwok.org> Co-authored-by: Łukasz Langa <lukasz@langa.pl> (cherry picked from commit fda4b2f) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent ddac87c commit e51a22b

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
@@ -568,12 +568,18 @@ provided. They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
568568

569569
.. note::
570570

571-
This function is not thread-safe.
571+
This function is not thread-safe when custom archivers registered
572+
with :func:`register_archive_format` are used. In this case it
573+
temporarily changes the current working directory of the process
574+
to perform archiving.
572575

573576
.. versionchanged:: 3.8
574577
The modern pax (POSIX.1-2001) format is now used instead of
575578
the legacy GNU format for archives created with ``format="tar"``.
576579

580+
.. versionchanged:: 3.10.6
581+
This function is now made thread-safe during creation of standard
582+
``.zip`` and tar archives.
577583

578584
.. function:: get_archive_formats()
579585

Lib/shutil.py

+65-34
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ def _get_uid(name):
887887
return None
888888

889889
def _make_tarball(base_name, base_dir, compress="gzip", verbose=0, dry_run=0,
890-
owner=None, group=None, logger=None):
890+
owner=None, group=None, logger=None, root_dir=None):
891891
"""Create a (possibly compressed) tar file from all the files under
892892
'base_dir'.
893893
@@ -944,14 +944,20 @@ def _set_uid_gid(tarinfo):
944944

945945
if not dry_run:
946946
tar = tarfile.open(archive_name, 'w|%s' % tar_compression)
947+
arcname = base_dir
948+
if root_dir is not None:
949+
base_dir = os.path.join(root_dir, base_dir)
947950
try:
948-
tar.add(base_dir, filter=_set_uid_gid)
951+
tar.add(base_dir, arcname, filter=_set_uid_gid)
949952
finally:
950953
tar.close()
951954

955+
if root_dir is not None:
956+
archive_name = os.path.abspath(archive_name)
952957
return archive_name
953958

954-
def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, logger=None):
959+
def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0,
960+
logger=None, owner=None, group=None, root_dir=None):
955961
"""Create a zip file from all the files under 'base_dir'.
956962
957963
The output zip file will be named 'base_name' + ".zip". Returns the
@@ -975,42 +981,60 @@ def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, logger=None):
975981
if not dry_run:
976982
with zipfile.ZipFile(zip_filename, "w",
977983
compression=zipfile.ZIP_DEFLATED) as zf:
978-
path = os.path.normpath(base_dir)
979-
if path != os.curdir:
980-
zf.write(path, path)
984+
arcname = os.path.normpath(base_dir)
985+
if root_dir is not None:
986+
base_dir = os.path.join(root_dir, base_dir)
987+
base_dir = os.path.normpath(base_dir)
988+
if arcname != os.curdir:
989+
zf.write(base_dir, arcname)
981990
if logger is not None:
982-
logger.info("adding '%s'", path)
991+
logger.info("adding '%s'", base_dir)
983992
for dirpath, dirnames, filenames in os.walk(base_dir):
993+
arcdirpath = dirpath
994+
if root_dir is not None:
995+
arcdirpath = os.path.relpath(arcdirpath, root_dir)
996+
arcdirpath = os.path.normpath(arcdirpath)
984997
for name in sorted(dirnames):
985-
path = os.path.normpath(os.path.join(dirpath, name))
986-
zf.write(path, path)
998+
path = os.path.join(dirpath, name)
999+
arcname = os.path.join(arcdirpath, name)
1000+
zf.write(path, arcname)
9871001
if logger is not None:
9881002
logger.info("adding '%s'", path)
9891003
for name in filenames:
990-
path = os.path.normpath(os.path.join(dirpath, name))
1004+
path = os.path.join(dirpath, name)
1005+
path = os.path.normpath(path)
9911006
if os.path.isfile(path):
992-
zf.write(path, path)
1007+
arcname = os.path.join(arcdirpath, name)
1008+
zf.write(path, arcname)
9931009
if logger is not None:
9941010
logger.info("adding '%s'", path)
9951011

1012+
if root_dir is not None:
1013+
zip_filename = os.path.abspath(zip_filename)
9961014
return zip_filename
9971015

1016+
# Maps the name of the archive format to a tuple containing:
1017+
# * the archiving function
1018+
# * extra keyword arguments
1019+
# * description
1020+
# * does it support the root_dir argument?
9981021
_ARCHIVE_FORMATS = {
999-
'tar': (_make_tarball, [('compress', None)], "uncompressed tar file"),
1022+
'tar': (_make_tarball, [('compress', None)],
1023+
"uncompressed tar file", True),
10001024
}
10011025

10021026
if _ZLIB_SUPPORTED:
10031027
_ARCHIVE_FORMATS['gztar'] = (_make_tarball, [('compress', 'gzip')],
1004-
"gzip'ed tar-file")
1005-
_ARCHIVE_FORMATS['zip'] = (_make_zipfile, [], "ZIP file")
1028+
"gzip'ed tar-file", True)
1029+
_ARCHIVE_FORMATS['zip'] = (_make_zipfile, [], "ZIP file", True)
10061030

10071031
if _BZ2_SUPPORTED:
10081032
_ARCHIVE_FORMATS['bztar'] = (_make_tarball, [('compress', 'bzip2')],
1009-
"bzip2'ed tar-file")
1033+
"bzip2'ed tar-file", True)
10101034

10111035
if _LZMA_SUPPORTED:
10121036
_ARCHIVE_FORMATS['xztar'] = (_make_tarball, [('compress', 'xz')],
1013-
"xz'ed tar-file")
1037+
"xz'ed tar-file", True)
10141038

10151039
def get_archive_formats():
10161040
"""Returns a list of supported formats for archiving and unarchiving.
@@ -1041,7 +1065,7 @@ def register_archive_format(name, function, extra_args=None, description=''):
10411065
if not isinstance(element, (tuple, list)) or len(element) !=2:
10421066
raise TypeError('extra_args elements are : (arg_name, value)')
10431067

1044-
_ARCHIVE_FORMATS[name] = (function, extra_args, description)
1068+
_ARCHIVE_FORMATS[name] = (function, extra_args, description, False)
10451069

10461070
def unregister_archive_format(name):
10471071
del _ARCHIVE_FORMATS[name]
@@ -1065,36 +1089,38 @@ def make_archive(base_name, format, root_dir=None, base_dir=None, verbose=0,
10651089
uses the current owner and group.
10661090
"""
10671091
sys.audit("shutil.make_archive", base_name, format, root_dir, base_dir)
1068-
save_cwd = os.getcwd()
1069-
if root_dir is not None:
1070-
if logger is not None:
1071-
logger.debug("changing into '%s'", root_dir)
1072-
base_name = os.path.abspath(base_name)
1073-
if not dry_run:
1074-
os.chdir(root_dir)
1075-
1076-
if base_dir is None:
1077-
base_dir = os.curdir
1078-
1079-
kwargs = {'dry_run': dry_run, 'logger': logger}
1080-
10811092
try:
10821093
format_info = _ARCHIVE_FORMATS[format]
10831094
except KeyError:
10841095
raise ValueError("unknown archive format '%s'" % format) from None
10851096

1097+
kwargs = {'dry_run': dry_run, 'logger': logger,
1098+
'owner': owner, 'group': group}
1099+
10861100
func = format_info[0]
10871101
for arg, val in format_info[1]:
10881102
kwargs[arg] = val
10891103

1090-
if format != 'zip':
1091-
kwargs['owner'] = owner
1092-
kwargs['group'] = group
1104+
if base_dir is None:
1105+
base_dir = os.curdir
1106+
1107+
support_root_dir = format_info[3]
1108+
save_cwd = None
1109+
if root_dir is not None:
1110+
if support_root_dir:
1111+
kwargs['root_dir'] = root_dir
1112+
else:
1113+
save_cwd = os.getcwd()
1114+
if logger is not None:
1115+
logger.debug("changing into '%s'", root_dir)
1116+
base_name = os.path.abspath(base_name)
1117+
if not dry_run:
1118+
os.chdir(root_dir)
10931119

10941120
try:
10951121
filename = func(base_name, base_dir, **kwargs)
10961122
finally:
1097-
if root_dir is not None:
1123+
if save_cwd is not None:
10981124
if logger is not None:
10991125
logger.debug("changing back to '%s'", save_cwd)
11001126
os.chdir(save_cwd)
@@ -1207,6 +1233,11 @@ def _unpack_tarfile(filename, extract_dir):
12071233
finally:
12081234
tarobj.close()
12091235

1236+
# Maps the name of the unpack format to a tuple containing:
1237+
# * extensions
1238+
# * the unpacking function
1239+
# * extra keyword arguments
1240+
# * description
12101241
_UNPACK_FORMATS = {
12111242
'tar': (['.tar'], _unpack_tarfile, [], "uncompressed tar file"),
12121243
'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")
@@ -1320,7 +1323,7 @@ def test_make_tarball(self):
13201323
work_dir = os.path.dirname(tmpdir2)
13211324
rel_base_name = os.path.join(os.path.basename(tmpdir2), 'archive')
13221325

1323-
with os_helper.change_cwd(work_dir):
1326+
with os_helper.change_cwd(work_dir), no_chdir:
13241327
base_name = os.path.abspath(rel_base_name)
13251328
tarball = make_archive(rel_base_name, 'gztar', root_dir, '.')
13261329

@@ -1334,7 +1337,7 @@ def test_make_tarball(self):
13341337
'./file1', './file2', './sub/file3'])
13351338

13361339
# trying an uncompressed one
1337-
with os_helper.change_cwd(work_dir):
1340+
with os_helper.change_cwd(work_dir), no_chdir:
13381341
tarball = make_archive(rel_base_name, 'tar', root_dir, '.')
13391342
self.assertEqual(tarball, base_name + '.tar')
13401343
self.assertTrue(os.path.isfile(tarball))
@@ -1370,7 +1373,8 @@ def _create_files(self, base_dir='dist'):
13701373
def test_tarfile_vs_tar(self):
13711374
root_dir, base_dir = self._create_files()
13721375
base_name = os.path.join(self.mkdtemp(), 'archive')
1373-
tarball = make_archive(base_name, 'gztar', root_dir, base_dir)
1376+
with no_chdir:
1377+
tarball = make_archive(base_name, 'gztar', root_dir, base_dir)
13741378

13751379
# check if the compressed tarball was created
13761380
self.assertEqual(tarball, base_name + '.tar.gz')
@@ -1387,13 +1391,15 @@ def test_tarfile_vs_tar(self):
13871391
self.assertEqual(self._tarinfo(tarball), self._tarinfo(tarball2))
13881392

13891393
# trying an uncompressed one
1390-
tarball = make_archive(base_name, 'tar', root_dir, base_dir)
1394+
with no_chdir:
1395+
tarball = make_archive(base_name, 'tar', root_dir, base_dir)
13911396
self.assertEqual(tarball, base_name + '.tar')
13921397
self.assertTrue(os.path.isfile(tarball))
13931398

13941399
# now for a dry_run
1395-
tarball = make_archive(base_name, 'tar', root_dir, base_dir,
1396-
dry_run=True)
1400+
with no_chdir:
1401+
tarball = make_archive(base_name, 'tar', root_dir, base_dir,
1402+
dry_run=True)
13971403
self.assertEqual(tarball, base_name + '.tar')
13981404
self.assertTrue(os.path.isfile(tarball))
13991405

@@ -1409,7 +1415,7 @@ def test_make_zipfile(self):
14091415
work_dir = os.path.dirname(tmpdir2)
14101416
rel_base_name = os.path.join(os.path.basename(tmpdir2), 'archive')
14111417

1412-
with os_helper.change_cwd(work_dir):
1418+
with os_helper.change_cwd(work_dir), no_chdir:
14131419
base_name = os.path.abspath(rel_base_name)
14141420
res = make_archive(rel_base_name, 'zip', root_dir)
14151421

@@ -1422,7 +1428,7 @@ def test_make_zipfile(self):
14221428
'dist/file1', 'dist/file2', 'dist/sub/file3',
14231429
'outer'])
14241430

1425-
with os_helper.change_cwd(work_dir):
1431+
with os_helper.change_cwd(work_dir), no_chdir:
14261432
base_name = os.path.abspath(rel_base_name)
14271433
res = make_archive(rel_base_name, 'zip', root_dir, base_dir)
14281434

@@ -1440,7 +1446,8 @@ def test_make_zipfile(self):
14401446
def test_zipfile_vs_zip(self):
14411447
root_dir, base_dir = self._create_files()
14421448
base_name = os.path.join(self.mkdtemp(), 'archive')
1443-
archive = make_archive(base_name, 'zip', root_dir, base_dir)
1449+
with no_chdir:
1450+
archive = make_archive(base_name, 'zip', root_dir, base_dir)
14441451

14451452
# check if ZIP file was created
14461453
self.assertEqual(archive, base_name + '.zip')
@@ -1466,7 +1473,8 @@ def test_zipfile_vs_zip(self):
14661473
def test_unzip_zipfile(self):
14671474
root_dir, base_dir = self._create_files()
14681475
base_name = os.path.join(self.mkdtemp(), 'archive')
1469-
archive = make_archive(base_name, 'zip', root_dir, base_dir)
1476+
with no_chdir:
1477+
archive = make_archive(base_name, 'zip', root_dir, base_dir)
14701478

14711479
# check if ZIP file was created
14721480
self.assertEqual(archive, base_name + '.zip')
@@ -1524,7 +1532,7 @@ def test_tarfile_root_owner(self):
15241532
base_name = os.path.join(self.mkdtemp(), 'archive')
15251533
group = grp.getgrgid(0)[0]
15261534
owner = pwd.getpwuid(0)[0]
1527-
with os_helper.change_cwd(root_dir):
1535+
with os_helper.change_cwd(root_dir), no_chdir:
15281536
archive_name = make_archive(base_name, 'gztar', root_dir, 'dist',
15291537
owner=owner, group=group)
15301538

@@ -1542,31 +1550,38 @@ def test_tarfile_root_owner(self):
15421550

15431551
def test_make_archive_cwd(self):
15441552
current_dir = os.getcwd()
1553+
root_dir = self.mkdtemp()
15451554
def _breaks(*args, **kw):
15461555
raise RuntimeError()
1556+
dirs = []
1557+
def _chdir(path):
1558+
dirs.append(path)
1559+
orig_chdir(path)
15471560

15481561
register_archive_format('xxx', _breaks, [], 'xxx file')
15491562
try:
1550-
try:
1551-
make_archive('xxx', 'xxx', root_dir=self.mkdtemp())
1552-
except Exception:
1553-
pass
1563+
with support.swap_attr(os, 'chdir', _chdir) as orig_chdir:
1564+
try:
1565+
make_archive('xxx', 'xxx', root_dir=root_dir)
1566+
except Exception:
1567+
pass
15541568
self.assertEqual(os.getcwd(), current_dir)
1569+
self.assertEqual(dirs, [root_dir, current_dir])
15551570
finally:
15561571
unregister_archive_format('xxx')
15571572

15581573
def test_make_tarfile_in_curdir(self):
15591574
# Issue #21280
15601575
root_dir = self.mkdtemp()
1561-
with os_helper.change_cwd(root_dir):
1576+
with os_helper.change_cwd(root_dir), no_chdir:
15621577
self.assertEqual(make_archive('test', 'tar'), 'test.tar')
15631578
self.assertTrue(os.path.isfile('test.tar'))
15641579

15651580
@support.requires_zlib()
15661581
def test_make_zipfile_in_curdir(self):
15671582
# Issue #21280
15681583
root_dir = self.mkdtemp()
1569-
with os_helper.change_cwd(root_dir):
1584+
with os_helper.change_cwd(root_dir), no_chdir:
15701585
self.assertEqual(make_archive('test', 'zip'), 'test.zip')
15711586
self.assertTrue(os.path.isfile('test.zip'))
15721587

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)