From 42cd03912f4d303d6a384134abcfb74e4b536522 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 00:10:34 +0800 Subject: [PATCH 01/17] Support custom extractor class --- Doc/library/zipfile.rst | 79 +++++++++++++++-- Lib/test/test_zipfile/test_core.py | 138 +++++++++++++++++++++++++++++ Lib/zipfile/__init__.py | 121 +++++++++++++++++++++---- 3 files changed, 313 insertions(+), 25 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 98d2a5e5cdf00e2..8604fc918e87e85 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -373,13 +373,14 @@ ZipFile objects object was changed from ``'r'`` to ``'rb'``. -.. method:: ZipFile.extract(member, path=None, pwd=None) +.. method:: ZipFile.extract(member, path=None, pwd=None, extractor=None) Extract a member from the archive to the current working directory; *member* - must be its full name or a :class:`ZipInfo` object. Its file information is - extracted as accurately as possible. *path* specifies a different directory - to extract to. *member* can be a filename or a :class:`ZipInfo` object. - *pwd* is the password used for encrypted files as a :class:`bytes` object. + must be its full name or a :class:`ZipInfo` object. *path* specifies a + different directory to extract to. *pwd* is the password used for encrypted + files as a :class:`bytes` object. *extractor* is a custom extractor class + that handles file attribute restoration (defaults to + :class:`ZipExtractorBase`, which restores no attributes). Returns the normalized path created (a directory or new file). @@ -393,6 +394,12 @@ ZipFile objects characters (``:``, ``<``, ``>``, ``|``, ``"``, ``?``, and ``*``) replaced by underscore (``_``). + .. note:: + + Use :meth:`extractall` when extracting multiple members. The + :meth:`extract` method may not restore all file attributes for the whole + directory hierarchy reliably. + .. versionchanged:: 3.6 Calling :meth:`extract` on a closed ZipFile will raise a :exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised. @@ -400,13 +407,18 @@ ZipFile objects .. versionchanged:: 3.6.2 The *path* parameter accepts a :term:`path-like object`. + .. versionchanged:: next + Added the *extractor* parameter. + -.. method:: ZipFile.extractall(path=None, members=None, pwd=None) +.. method:: ZipFile.extractall(path=None, members=None, pwd=None, extractor=None) Extract all members from the archive to the current working directory. *path* specifies a different directory to extract to. *members* is optional and must be a subset of the list returned by :meth:`namelist`. *pwd* is the password - used for encrypted files as a :class:`bytes` object. + used for encrypted files as a :class:`bytes` object. *extractor* is a custom + extractor class that handles file attribute restoration (defaults to + :class:`ZipExtractorBase`, which restores no attributes). .. warning:: @@ -423,6 +435,9 @@ ZipFile objects .. versionchanged:: 3.6.2 The *path* parameter accepts a :term:`path-like object`. + .. versionchanged:: next + Added the *extractor* parameter. + .. method:: ZipFile.printdir() @@ -1041,6 +1056,56 @@ Instances have the following methods and attributes: Size of the uncompressed file. +.. _extractors: + +Extractors +---------- + +An extractor class can be passed to :meth:`ZipFile.extract` or +:meth:`ZipFile.extractall` to handle file attribute restoration. + +To create a custom extractor, subclass :class:`ZipExtractorBase` and override +its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that +ignores errors when restoring file attributes: + + class SafeZipExtractorTimeMode(zipfile.ZipExtractorTimeMode): + def restore_attributes(self, targetpath, zinfo): + try: + super().restore_attributes(targetpath, zinfo) + except (OSError, OverflowError, ValueError): + pass + + with zipfile.ZipFile('spam.zip') as myzip: + myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) + +.. class:: ZipExtractorBase(archive, path=None, pwd=None): + + The base extractor class that restores no file attributes. + + +.. class:: ZipExtractorTime(archive, path=None, pwd=None): + + An extractor class that restores file mtime and atime. + + +.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None): + + An extractor class that restores file mtime, atime, and executable bits + (``0o111``). + + +.. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None): + + An extractor class that restores file mtime, atime, and safe file mode + bits (``0o777``). + + +.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None): + + An extractor class that restores file mtime, atime, and all mode bits + (``0o7777``). + + .. _zipfile-commandline: .. program:: zipfile diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index cd498ba13e6f461..05f0d3997372cda 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -3948,6 +3948,144 @@ def _test_extract_hackers_arcnames(self, hacknames): unlink(TESTFN2) +class ExtractorTests(unittest.TestCase): + test_data = [ + ('folder1/', b'', (1990, 1, 2, 0, 0, 0), (0o47777 << 16) | 0x10), + ('folder1/file1.txt', b'qawsedrftg', (1990, 2, 2, 0, 0, 0), 0o106642 << 16), + ('implicit_folder/file2.txt', b'azsxdcfvgb', (1990, 3, 2, 0, 0, 0), 0o100246 << 16), + ] + + @classmethod + def setUpClass(cls): + os.mkdir(TESTFNDIR) + cls.dmode = os.stat(TESTFNDIR).st_mode + rmtree(TESTFNDIR) + + with open(TESTFN, 'wb'): + pass + cls.fmode = os.stat(TESTFN).st_mode + os.unlink(TESTFN) + + def setUp(self): + os.mkdir(TESTFNDIR) + + def tearDown(self): + os.unlink(TESTFN) + rmtree(TESTFNDIR) + + def make_test_archive(self, data): + with zipfile.ZipFile(TESTFN, 'w') as zipfp: + for filename, content, dt, ext_attr in data: + zinfo = zipfile.ZipInfo(filename, dt) + zinfo.external_attr = ext_attr + zipfp.writestr(zinfo, content) + + def test_extract_default(self): + """Should not restore attributes by default.""" + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + zipfp.extract(filename, TESTFNDIR) + now = time.time() + outfile = os.path.join(TESTFNDIR, filename) + mtime = os.path.getmtime(outfile) + self.assertAlmostEqual(mtime, now, delta=5) + + def test_extract_time(self): + """Should restore mtime/atime.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + zipfp.extract(filename, TESTFNDIR, extractor=extractor_cls) + outfile = os.path.join(TESTFNDIR, filename) + mtuple = time.localtime(os.path.getmtime(outfile))[:6] + atuple = time.localtime(os.path.getatime(outfile))[:6] + self.assertEqual(mtuple, dt) + self.assertEqual(atuple, dt) + + def test_extract_all_default(self): + """Should not restore attributes by default.""" + self.make_test_archive(self.test_data) + now = time.time() + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + outfile = os.path.join(TESTFNDIR, filename) + mtime = os.path.getmtime(outfile) + self.assertAlmostEqual(mtime, now, delta=5) + + def test_extract_all_time(self): + """Should restore mtime/atime.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=extractor_cls) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + outfile = os.path.join(TESTFNDIR, filename) + mtuple = time.localtime(os.path.getmtime(outfile))[:6] + atuple = time.localtime(os.path.getatime(outfile))[:6] + self.assertEqual(mtuple, dt) + self.assertEqual(atuple, dt) + + @unittest.skipIf(sys.platform == 'win32' or sys.platform == 'wasi', 'Requires file permissions') + def test_extract_all_mode(self): + """Should restore (masked) mode.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=extractor_cls) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + outfile = os.path.join(TESTFNDIR, filename) + default_mode = (self.dmode if os.path.isdir(outfile) else self.fmode) + expected_mode = (default_mode & ~mask | ((ext_attr >> 16) & mask)) + mode = os.stat(outfile).st_mode + self.assertEqual(oct(mode), oct(expected_mode)) + + @unittest.skipIf(sys.platform == 'win32' or sys.platform == 'wasi', 'Requires file permissions') + def test_extract_all_hierarchy(self): + """Should safely extract contents into a non-writable directory.""" + test_data = [ + ('folder1/', b'', (1990, 1, 2, 0, 0, 0), (0o40100 << 16) | 0x10), + ('folder1/file1.txt', b'qawsedrftg', (1990, 2, 2, 0, 0, 0), 0o100000 << 16), + ('folder1/file2.txt', b'azsxdcfvgb', (1990, 3, 2, 0, 0, 0), 0o100777 << 16), + ] + mask = 0o7777 + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=zipfile.ZipExtractorTimeMode) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + outfile = os.path.join(TESTFNDIR, filename) + default_mode = (self.dmode if os.path.isdir(outfile) else self.fmode) + expected_mode = (default_mode & ~mask | ((ext_attr >> 16) & mask)) + mode = os.stat(outfile).st_mode + self.assertEqual(oct(mode), oct(expected_mode)) + + class OverwriteTests(archiver_tests.OverwriteTests, unittest.TestCase): testdir = TESTFN diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e87..10dd5edf57b1c93 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -38,7 +38,9 @@ __all__ = ["BadZipFile", "BadZipfile", "error", "ZIP_STORED", "ZIP_DEFLATED", "ZIP_BZIP2", "ZIP_LZMA", "ZIP_ZSTANDARD", "is_zipfile", "ZipInfo", "ZipFile", "PyZipFile", - "LargeZipFile", "Path"] + "ZipExtractorBase", "ZipExtractorTime", "ZipExtractorTimeMode", + "ZipExtractorTimeModeSafe", "ZipExtractorTimeModeX", "LargeZipFile", + "Path"] class BadZipFile(Exception): pass @@ -1864,6 +1866,91 @@ def _copy_bytes(self, fp, old_offset, new_offset, size): read_size += len(data) +class ZipExtractorBase: + """The base extractor class that does not restore attributes.""" + allowed_mode = 0o7777 + + def __init__(self, archive, path=None, pwd=None): + self.archive = archive + if path is None: + self.target_path = os.getcwd() + else: + self.target_path = os.fspath(path) + self.pwd = pwd + self._dirs = {} + self._in_ctx = False + + def __enter__(self): + self._in_ctx = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + try: + self.finalize(exc_type, exc_value, traceback) + finally: + self._in_ctx = False + + def __call__(self, zinfo): + targetpath = self.archive._extract_member(zinfo, self.target_path, self.pwd) + + # Delay restoring attributes for directories since permissions + # can interfere with extraction and extracting contents can + # reset mtime. + if zinfo.is_dir(): + self._dirs[zinfo] = targetpath + else: + self.restore_attributes(targetpath, zinfo) + + # auto-finalize for a standalone call + if not self._in_ctx: + self.finalize() + + return targetpath + + def finalize(self, exc_type=None, exc_value=None, traceback=None): + # restore attributes for directories from bottom to top + items = sorted(self._dirs.items(), key=lambda i: i[0].filename, reverse=True) + for zinfo, targetpath in items: + # skip if directory missing + if not os.path.isdir(targetpath): + continue + + self.restore_attributes(targetpath, zinfo) + + self._dirs.clear() + + def restore_attributes(self, targetpath, zinfo): + pass + + def utime(self, targetpath, zinfo): + dt = time.mktime(zinfo.date_time + (0, 0, -1)) + os.utime(targetpath, (dt, dt)) + + def chmod(self, targetpath, zinfo): + mode = (zinfo.external_attr >> 16) & self.allowed_mode + mode = os.stat(targetpath).st_mode & ~self.allowed_mode | mode + os.chmod(targetpath, mode) + +class ZipExtractorTime(ZipExtractorBase): + """Restores time (if platform supports).""" + def restore_attributes(self, targetpath, zinfo): + self.utime(targetpath, zinfo) + +class ZipExtractorTimeMode(ZipExtractorTime): + """Restores time and all mode (if platform supports).""" + def restore_attributes(self, targetpath, zinfo): + self.utime(targetpath, zinfo) + self.chmod(targetpath, zinfo) + +class ZipExtractorTimeModeSafe(ZipExtractorTimeMode): + """Restores time and safe mode (if platform supports).""" + allowed_mode = 0o777 + +class ZipExtractorTimeModeX(ZipExtractorTimeMode): + """Restores time and x mode (if platform supports).""" + allowed_mode = 0o111 + + class ZipFile: """ Class with methods to open, read, write, close, list zip files. @@ -2313,37 +2400,35 @@ def _open_to_write(self, zinfo, force_zip64=False): self._writing = True return _ZipWriteFile(self, zinfo, zip64) - def extract(self, member, path=None, pwd=None): + def extract(self, member, path=None, pwd=None, extractor=None): """Extract a member from the archive to the current working directory, using its full name. Its file information is extracted as accurately as possible. 'member' may be a filename or a ZipInfo object. You can specify a different directory using 'path'. You can specify the - password to decrypt the file using 'pwd'. + password to decrypt the file using 'pwd'. 'extractor' specifies a + custom extractor class. """ - if path is None: - path = os.getcwd() - else: - path = os.fspath(path) + if extractor is None: + extractor = ZipExtractorBase + zinfo = member if isinstance(member, ZipInfo) else self.getinfo(member) + return extractor(self, path, pwd=pwd)(zinfo) - return self._extract_member(member, path, pwd) - - def extractall(self, path=None, members=None, pwd=None): + def extractall(self, path=None, members=None, pwd=None, extractor=None): """Extract all members from the archive to the current working directory. 'path' specifies a different directory to extract to. 'members' is optional and must be a subset of the list returned by namelist(). You can specify the password to decrypt all files - using 'pwd'. + using 'pwd'. 'extractor' specifies a custom extractor class. """ if members is None: members = self.namelist() + if extractor is None: + extractor = ZipExtractorBase - if path is None: - path = os.getcwd() - else: - path = os.fspath(path) - - for zipinfo in members: - self._extract_member(zipinfo, path, pwd) + zinfos = [m if isinstance(m, ZipInfo) else self.getinfo(m) for m in members] + with extractor(self, path, pwd=pwd) as extr: + for zinfo in zinfos: + extr(zinfo) def remove(self, zinfo_or_arcname): """Remove a member from the archive.""" From 3816162c2e56a9083e2d5b5ad8a76aed068b7b05 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:46:18 +0000 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst diff --git a/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst new file mode 100644 index 000000000000000..0f145a43baa4a8c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst @@ -0,0 +1 @@ +Add ``extractor`` parameter for :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` to support customizable file attributes restoration when extracting files from a ZIP archive. From e7ad27354031937319cbc7558037ad9f566a1f67 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 12:54:19 +0800 Subject: [PATCH 03/17] Fix syntax in NEWS --- .../next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst index 0f145a43baa4a8c..ed0adb4edf2ba9c 100644 --- a/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst +++ b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst @@ -1 +1 @@ -Add ``extractor`` parameter for :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` to support customizable file attributes restoration when extracting files from a ZIP archive. +Add ``extractor`` parameter for :meth:`zipfile.ZipFile.extract` and :meth:`zipfile.ZipFile.extractall` to support customizable file attributes restoration when extracting files from a ZIP archive. From 770f01f161d81570f7bbb9aef76552a56b9df3a3 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 12:57:44 +0800 Subject: [PATCH 04/17] Fix doc syntax --- Doc/library/zipfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 8604fc918e87e85..a84ea270db5ba2f 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1066,7 +1066,7 @@ An extractor class can be passed to :meth:`ZipFile.extract` or To create a custom extractor, subclass :class:`ZipExtractorBase` and override its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that -ignores errors when restoring file attributes: +ignores errors when restoring file attributes:: class SafeZipExtractorTimeMode(zipfile.ZipExtractorTimeMode): def restore_attributes(self, targetpath, zinfo): From 39c530849bdc24856ee25d2d29db4f321fd0b38c Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 12:59:46 +0800 Subject: [PATCH 05/17] Fix issue number --- ...1.1DhL-c.rst => 2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Misc/NEWS.d/next/Library/{2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst => 2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst} (100%) diff --git a/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst similarity index 100% rename from Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76531.1DhL-c.rst rename to Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst From 2c48e1bc7948c031e3ac8bad8fdbb25d5c435fa9 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:06:29 +0800 Subject: [PATCH 06/17] Fix code block --- Doc/library/zipfile.rst | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index a84ea270db5ba2f..29a242ad04b6c3e 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1068,15 +1068,17 @@ To create a custom extractor, subclass :class:`ZipExtractorBase` and override its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that ignores errors when restoring file attributes:: - class SafeZipExtractorTimeMode(zipfile.ZipExtractorTimeMode): - def restore_attributes(self, targetpath, zinfo): - try: - super().restore_attributes(targetpath, zinfo) - except (OSError, OverflowError, ValueError): - pass - - with zipfile.ZipFile('spam.zip') as myzip: - myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) +.. code-block:: python + + class SafeZipExtractorTimeMode(zipfile.ZipExtractorTimeMode): + def restore_attributes(self, targetpath, zinfo): + try: + super().restore_attributes(targetpath, zinfo) + except (OSError, OverflowError, ValueError): + pass + + with zipfile.ZipFile('spam.zip') as myzip: + myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) .. class:: ZipExtractorBase(archive, path=None, pwd=None): From 2def0c4267305a0f6aaf46924f216bf4daab87b4 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:11:42 +0800 Subject: [PATCH 07/17] Fix syntax for extractors --- Doc/library/zipfile.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 29a242ad04b6c3e..c1ba37c87a3e063 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1080,29 +1080,29 @@ ignores errors when restoring file attributes:: with zipfile.ZipFile('spam.zip') as myzip: myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) -.. class:: ZipExtractorBase(archive, path=None, pwd=None): +.. class:: ZipExtractorBase(archive, path=None, pwd=None) The base extractor class that restores no file attributes. -.. class:: ZipExtractorTime(archive, path=None, pwd=None): +.. class:: ZipExtractorTime(archive, path=None, pwd=None) An extractor class that restores file mtime and atime. -.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None): +.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) An extractor class that restores file mtime, atime, and executable bits (``0o111``). -.. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None): +.. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None) An extractor class that restores file mtime, atime, and safe file mode bits (``0o777``). -.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None): +.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) An extractor class that restores file mtime, atime, and all mode bits (``0o7777``). From b635aa50130df6d9406392d729cd9fefd3894b1f Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:15:18 +0800 Subject: [PATCH 08/17] Fix syntax for code block --- Doc/library/zipfile.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index c1ba37c87a3e063..9fb19ac782d430d 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1080,6 +1080,7 @@ ignores errors when restoring file attributes:: with zipfile.ZipFile('spam.zip') as myzip: myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) + .. class:: ZipExtractorBase(archive, path=None, pwd=None) The base extractor class that restores no file attributes. From 506e0021d523cc3e9fd12233c417919c84f468ab Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:20:18 +0800 Subject: [PATCH 09/17] Fix syntax for code block --- Doc/library/zipfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 9fb19ac782d430d..4c520306d385b87 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1066,7 +1066,7 @@ An extractor class can be passed to :meth:`ZipFile.extract` or To create a custom extractor, subclass :class:`ZipExtractorBase` and override its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that -ignores errors when restoring file attributes:: +ignores errors when restoring file attributes: .. code-block:: python From 82b8e458468dee599f04b7a7ce1b840be2ecf4b4 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:34:02 +0800 Subject: [PATCH 10/17] Fix linking error --- Doc/library/zipfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 4c520306d385b87..a0e1c75e97f6a6d 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1065,7 +1065,7 @@ An extractor class can be passed to :meth:`ZipFile.extract` or :meth:`ZipFile.extractall` to handle file attribute restoration. To create a custom extractor, subclass :class:`ZipExtractorBase` and override -its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that +its :meth:`!~ZipExtractorBase.restore_attributes`. Here is an example that ignores errors when restoring file attributes: .. code-block:: python From 61c3c0fe073f1421e6be35ba2e7bb9f6ac95aa4a Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 13:36:08 +0800 Subject: [PATCH 11/17] Fix syntax error --- Doc/library/zipfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index a0e1c75e97f6a6d..f7f37a5d02053c0 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1065,7 +1065,7 @@ An extractor class can be passed to :meth:`ZipFile.extract` or :meth:`ZipFile.extractall` to handle file attribute restoration. To create a custom extractor, subclass :class:`ZipExtractorBase` and override -its :meth:`!~ZipExtractorBase.restore_attributes`. Here is an example that +its :meth:`!ZipExtractorBase.restore_attributes`. Here is an example that ignores errors when restoring file attributes: .. code-block:: python From 245710b98c218a398dbde4d0749d5f5111e00066 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 15:00:39 +0800 Subject: [PATCH 12/17] Always call with context --- Lib/zipfile/__init__.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 10dd5edf57b1c93..79fb64fbe8f0cd5 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -1878,17 +1878,12 @@ def __init__(self, archive, path=None, pwd=None): self.target_path = os.fspath(path) self.pwd = pwd self._dirs = {} - self._in_ctx = False def __enter__(self): - self._in_ctx = True return self def __exit__(self, exc_type, exc_value, traceback): - try: - self.finalize(exc_type, exc_value, traceback) - finally: - self._in_ctx = False + self.finalize(exc_type, exc_value, traceback) def __call__(self, zinfo): targetpath = self.archive._extract_member(zinfo, self.target_path, self.pwd) @@ -1901,10 +1896,6 @@ def __call__(self, zinfo): else: self.restore_attributes(targetpath, zinfo) - # auto-finalize for a standalone call - if not self._in_ctx: - self.finalize() - return targetpath def finalize(self, exc_type=None, exc_value=None, traceback=None): @@ -1917,8 +1908,6 @@ def finalize(self, exc_type=None, exc_value=None, traceback=None): self.restore_attributes(targetpath, zinfo) - self._dirs.clear() - def restore_attributes(self, targetpath, zinfo): pass @@ -2411,7 +2400,8 @@ def extract(self, member, path=None, pwd=None, extractor=None): if extractor is None: extractor = ZipExtractorBase zinfo = member if isinstance(member, ZipInfo) else self.getinfo(member) - return extractor(self, path, pwd=pwd)(zinfo) + with extractor(self, path, pwd=pwd) as extr: + return extr(zinfo) def extractall(self, path=None, members=None, pwd=None, extractor=None): """Extract all members from the archive to the current working From 19e2e10eb1109f695c636f61b571a25c318279de Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 15:50:07 +0800 Subject: [PATCH 13/17] Improve doc for extractors --- Doc/library/zipfile.rst | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index f7f37a5d02053c0..b9a704332952a0b 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1065,7 +1065,7 @@ An extractor class can be passed to :meth:`ZipFile.extract` or :meth:`ZipFile.extractall` to handle file attribute restoration. To create a custom extractor, subclass :class:`ZipExtractorBase` and override -its :meth:`!ZipExtractorBase.restore_attributes`. Here is an example that +its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that ignores errors when restoring file attributes: .. code-block:: python @@ -1085,28 +1085,47 @@ ignores errors when restoring file attributes: The base extractor class that restores no file attributes. + .. method:: restore_attributes(targetpath, zinfo) + + Called for every extracted member *zinfo* at *targetpath* to restore + its attributes. This method does nothing by default. Subclasses + should override this method to customize attribute restoration. + + .. method:: finalize(exc_type=None, exc_value=None, traceback=None) + + Called after all members have been extracted (or when an exception + occurs) to restore attributes for extracted directories in reverse + (bottom-up) order. This prevents issues where extracting contents into + a directory resets its modification time or fails if it's not writable. + Subclasses can override this method to customize cleanup or error + recovery logic. + .. class:: ZipExtractorTime(archive, path=None, pwd=None) + :base: ZipExtractorBase An extractor class that restores file mtime and atime. -.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) +.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) + :base: ZipExtractorTime - An extractor class that restores file mtime, atime, and executable bits - (``0o111``). + An extractor class that restores file mtime, atime, and all mode bits + (``0o7777``). .. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None) + :base: ZipExtractorTimeMode An extractor class that restores file mtime, atime, and safe file mode bits (``0o777``). -.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) +.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) + :base: ZipExtractorTimeMode - An extractor class that restores file mtime, atime, and all mode bits - (``0o7777``). + An extractor class that restores file mtime, atime, and executable bits + (``0o111``). .. _zipfile-commandline: From 15736f0440aa4c5819b3ac8b8122fae1454b9094 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 15:56:28 +0800 Subject: [PATCH 14/17] Fix syntax --- Doc/library/zipfile.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index b9a704332952a0b..30bcab680d2b37d 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1102,27 +1102,23 @@ ignores errors when restoring file attributes: .. class:: ZipExtractorTime(archive, path=None, pwd=None) - :base: ZipExtractorBase An extractor class that restores file mtime and atime. .. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) - :base: ZipExtractorTime An extractor class that restores file mtime, atime, and all mode bits (``0o7777``). .. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None) - :base: ZipExtractorTimeMode An extractor class that restores file mtime, atime, and safe file mode bits (``0o777``). .. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) - :base: ZipExtractorTimeMode An extractor class that restores file mtime, atime, and executable bits (``0o111``). From e73fee447a313c210843abe42ce35d5a5e78d5e0 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 16:25:24 +0800 Subject: [PATCH 15/17] Improve doc about extractors --- Doc/library/zipfile.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 30bcab680d2b37d..1a06ae99deee6b9 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1064,9 +1064,9 @@ Extractors An extractor class can be passed to :meth:`ZipFile.extract` or :meth:`ZipFile.extractall` to handle file attribute restoration. -To create a custom extractor, subclass :class:`ZipExtractorBase` and override -its :meth:`~ZipExtractorBase.restore_attributes`. Here is an example that -ignores errors when restoring file attributes: +To create a custom extractor, subclass :class:`ZipExtractorBase` and implement +the :meth:`~ZipExtractorBase.restore_attributes` method. Here is an example +that ignores errors when restoring file attributes: .. code-block:: python @@ -1103,25 +1103,25 @@ ignores errors when restoring file attributes: .. class:: ZipExtractorTime(archive, path=None, pwd=None) - An extractor class that restores file mtime and atime. + A subclass of :class:`ZipExtractorBase` that restores file mtime and atime. .. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) - An extractor class that restores file mtime, atime, and all mode bits - (``0o7777``). + A subclass of :class:`ZipExtractorTime` that restores file mtime, atime, and + all mode bits (``0o7777``). .. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None) - An extractor class that restores file mtime, atime, and safe file mode - bits (``0o777``). + A subclass of :class:`ZipExtractorTimeMode` that restores file mtime, atime, + and safe file mode bits (``0o777``). .. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) - An extractor class that restores file mtime, atime, and executable bits - (``0o111``). + A subclass of :class:`ZipExtractorTimeMode` that restores file mtime, atime, + and executable bits (``0o111``). .. _zipfile-commandline: From f081de736730e19995d989eee941f43ffd5affc2 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 17:27:33 +0800 Subject: [PATCH 16/17] Add link to extractors --- Doc/library/zipfile.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 1a06ae99deee6b9..05031ff4e9889d7 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -378,9 +378,9 @@ ZipFile objects Extract a member from the archive to the current working directory; *member* must be its full name or a :class:`ZipInfo` object. *path* specifies a different directory to extract to. *pwd* is the password used for encrypted - files as a :class:`bytes` object. *extractor* is a custom extractor class - that handles file attribute restoration (defaults to - :class:`ZipExtractorBase`, which restores no attributes). + files as a :class:`bytes` object. *extractor* is a custom + :ref:`extractor class ` that handles file attribute restoration + (defaults to :class:`ZipExtractorBase`, which restores no attributes). Returns the normalized path created (a directory or new file). @@ -417,8 +417,8 @@ ZipFile objects specifies a different directory to extract to. *members* is optional and must be a subset of the list returned by :meth:`namelist`. *pwd* is the password used for encrypted files as a :class:`bytes` object. *extractor* is a custom - extractor class that handles file attribute restoration (defaults to - :class:`ZipExtractorBase`, which restores no attributes). + :ref:`extractor class ` that handles file attribute restoration + (defaults to :class:`ZipExtractorBase`, which restores no attributes). .. warning:: From 4a8612bfe74d0bac80ce9e8b6b20011d2a2aac09 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 26 Jul 2026 20:31:07 +0800 Subject: [PATCH 17/17] Add simple error capturing --- Lib/zipfile/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 79fb64fbe8f0cd5..b017af201a1eab3 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -1868,6 +1868,7 @@ def _copy_bytes(self, fp, old_offset, new_offset, size): class ZipExtractorBase: """The base extractor class that does not restore attributes.""" + debug = 0 allowed_mode = 0o7777 def __init__(self, archive, path=None, pwd=None): @@ -1879,6 +1880,10 @@ def __init__(self, archive, path=None, pwd=None): self.pwd = pwd self._dirs = {} + def _debug(self, level, *msg, file=sys.stderr): + if level <= self.debug: + print(*msg, file=file) + def __enter__(self): return self @@ -1923,13 +1928,19 @@ def chmod(self, targetpath, zinfo): class ZipExtractorTime(ZipExtractorBase): """Restores time (if platform supports).""" def restore_attributes(self, targetpath, zinfo): - self.utime(targetpath, zinfo) + try: + self.utime(targetpath, zinfo) + except OSError as exc: + self._debug(1, f'zipfile: {exc}') class ZipExtractorTimeMode(ZipExtractorTime): """Restores time and all mode (if platform supports).""" def restore_attributes(self, targetpath, zinfo): - self.utime(targetpath, zinfo) - self.chmod(targetpath, zinfo) + try: + self.utime(targetpath, zinfo) + self.chmod(targetpath, zinfo) + except OSError as exc: + self._debug(1, f'zipfile: {exc}') class ZipExtractorTimeModeSafe(ZipExtractorTimeMode): """Restores time and safe mode (if platform supports)."""