diff --git a/Lib/shutil.py b/Lib/shutil.py index 8d8fe145567822..fea2fe0b405a6a 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1063,10 +1063,28 @@ def _set_uid_gid(tarinfo): def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, logger=None, owner=None, group=None, root_dir=None): - """Create a zip file from all the files under 'base_dir'. - - The output zip file will be named 'base_name' + ".zip". Returns the - name of the output zip file. + """Create a zip file. Returns the name of the zip file. + + - `base_name` is used as the name for the zip file, with a suffix + of '.zip'. + - `base_dir` is the directory containing the files to be included + in the zip file. The zip file is created at the same level in the + directory structure as `base_dir`. + + Symbolic links to files and directories are followed and the + targets of the links included in the zip file. This matches the + default behaviour of command-line zip utilities on + Linux/UNIX/Windows systems. + + Hard links to files are followed and the targets of the links + included in the zip file. There is no de-duplication of multiple + hard links to the same file that is provided by other formats, + e.g. tar. + + CAUTION: This function uses os.walk() to prepare the list of files to be + included in the zip file. os.walk() does not keep track of which + directories have already been visited. A symbolic link to a directory that + is a parent of itself will lead to infinite recursion. """ import zipfile # late import for breaking circular dependency @@ -1094,7 +1112,7 @@ def _make_zipfile(base_name, base_dir, verbose=0, dry_run=0, zf.write(base_dir, arcname) if logger is not None: logger.info("adding '%s'", base_dir) - for dirpath, dirnames, filenames in os.walk(base_dir): + for dirpath, dirnames, filenames in os.walk(base_dir, followlinks=True): arcdirpath = dirpath if root_dir is not None: arcdirpath = os.path.relpath(arcdirpath, root_dir) @@ -1183,16 +1201,16 @@ def unregister_archive_format(name): def make_archive(base_name, format, root_dir=None, base_dir=None, verbose=0, dry_run=0, owner=None, group=None, logger=None): - """Create an archive file (eg. zip or tar). + """Create an archive file (e.g. zip or tar). 'base_name' is the name of the file to create, minus any format-specific extension; 'format' is the archive format: one of "zip", "tar", "gztar", "bztar", "xztar", or "zstdtar". Or any other registered format. 'root_dir' is a directory that will be the root directory of the - archive; ie. we typically chdir into 'root_dir' before creating the + archive; i.e. we typically chdir into 'root_dir' before creating the archive. 'base_dir' is the directory where we start archiving from; - ie. 'base_dir' will be the common prefix of all files and + i.e. 'base_dir' will be the common prefix of all files and directories in the archive. 'root_dir' and 'base_dir' both default to the current directory. Returns the name of the archive file. diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index ebb6cf88336249..0e00109203be03 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1636,6 +1636,37 @@ def _create_files(self, base_dir='dist'): create_file((root_dir, 'outer'), 'xxx') return root_dir, base_dir + def _create_files_symlinks(self, base_dir="symlinks"): + # Create a test structure containing symbolic links to files and + # directories. + root_dir = self.mkdtemp() + wd = os.path.join(root_dir, base_dir) + os.mkdir(wd) + # Create a regular file. + create_file( + (wd, 'file1'), + 'This is file1.' + ) + # Create a symbolic link to the file. + os.symlink( + os.path.join(wd, 'file1'), + os.path.join(wd, 'link1'), + ) + # Create a sub-directory. + os.mkdir(os.path.join(wd, 'sub')) + # Create a regular file in the sub-directory. + create_file( + (wd, 'sub', 'file2'), + 'This is a file2.' + ) + # Create a symbolic link to the sub-directory. + os.symlink( + os.path.join(wd, 'sub'), + os.path.join(wd, 'sub2'), + target_is_directory=True, + ) + return root_dir, base_dir + @support.requires_zlib() def test_make_tarfile(self): root_dir, base_dir = self._create_files() @@ -1882,6 +1913,47 @@ def test_zipfile_vs_zip(self): names2 = zf.namelist() self.assertEqual(sorted(names), sorted(names2)) + @os_helper.skip_unless_symlink + def test_make_zipfile_symlink_behaviour(self): + # Test that symbolic links for both file and directories are resolved + # to their targets when shutil.make_archive() is used to make a zip + # file, to match default command-line zip behaviour in + # Linux/UNIX/Windows. + # + # If this test is being skipped, it is because either the operating + # environment does not support symbolic links, or you do not have the + # necessary permissions to create them. For Windows (10 and above) the + # test must be invoked from an elevated command prompt or with + # Developer Mode turned on. + root_dir, base_dir = self._create_files_symlinks() + name = os.path.join(root_dir, 'z') + archive = shutil.make_archive( + name, "zip", root_dir=root_dir, base_dir=base_dir + ) + # Check we have a zip file. + self.assertTrue(zipfile.is_zipfile(archive)) + with zipfile.ZipFile(archive) as zf: + extract_dir = os.path.join(root_dir, base_dir, 'extract') + zf.extractall(extract_dir) + try: + # If symbolic link sub2 that targets directory sub1 was + # preserved as a link then sub2/file2 will not exist. + self.assertTrue( + os.path.exists( + os.path.join(extract_dir, base_dir, 'sub2/file2') + ) + ) + # If symbolic link link1 that targets file1 was preserved then + # it will be a symbolic link. + self.assertFalse( + os.path.islink( + os.path.join(extract_dir, base_dir, 'link1') + ) + ) + finally: + # Clean up. + shutil.rmtree(root_dir) + @support.requires_zlib() @unittest.skipUnless(shutil.which('unzip'), 'Need the unzip command to run') diff --git a/Misc/ACKS b/Misc/ACKS index 0812b229e0ada4..c56800990a68b8 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -498,6 +498,7 @@ Eugene Dvurechenski Karmen Dykstra Josip Dzolonga Maxim Dzumanenko +Thomas Earp Hans Eckardt Rodolpho Eckhardt Ulrich Eckhardt diff --git a/Misc/NEWS.d/next/Library/2025-10-09-15-50-12.gh-issue-139679.saib2u.rst b/Misc/NEWS.d/next/Library/2025-10-09-15-50-12.gh-issue-139679.saib2u.rst new file mode 100644 index 00000000000000..f0ee197077083a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-09-15-50-12.gh-issue-139679.saib2u.rst @@ -0,0 +1 @@ +The default behaviour of shutil.make_archive(), in relation to handling of symbolic links when creating zip files, now matches the default behaviour of command-line zip utilities on Linux/UNIX/Windows: symbolic links to both files and directories are resolved to their targets and included in the zip file.