From 14c1df4894398b1090ccc3212485de628dc9da54 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 1 Aug 2026 19:27:39 +0300 Subject: [PATCH 1/2] gh-155039: Support copy.replace() for zipfile.ZipInfo Co-Authored-By: Claude Opus 5 (1M context) --- Doc/library/zipfile.rst | 7 ++++ Lib/test/test_zipfile/test_core.py | 37 +++++++++++++++++++ Lib/zipfile/__init__.py | 22 +++++++++++ ...-08-01-19-00-00.gh-issue-155039.Zp1nFo.rst | 1 + 4 files changed, 67 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-08-01-19-00-00.gh-issue-155039.Zp1nFo.rst diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 98d2a5e5cdf00e2..dd7476e8035d6b0 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1041,6 +1041,13 @@ Instances have the following methods and attributes: Size of the uncompressed file. +:class:`ZipInfo` objects support :func:`copy.replace`, +which returns a copy of the object with the specified attributes replaced. + +.. versionchanged:: next + Added support of :func:`copy.replace`. + + .. _zipfile-commandline: .. program:: zipfile diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index cd498ba13e6f461..f52c31205936621 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -1,6 +1,7 @@ import _pyio import array import contextlib +import copy import errno import importlib.util import io @@ -5566,6 +5567,42 @@ def test_compresslevel_property(self): self.assertEqual(zinfo.compress_level, 8) self.assertEqual(zinfo._compresslevel, 8) + def test_replace(self): + zinfo = zipfile.ZipInfo('name.txt', (2020, 1, 2, 3, 4, 5)) + zinfo.file_size = 100 + zinfo.CRC = 42 + new = copy.replace(zinfo, filename='other.txt', + compress_type=zipfile.ZIP_DEFLATED) + self.assertIsInstance(new, zipfile.ZipInfo) + self.assertEqual(new.filename, 'other.txt') + self.assertEqual(new.orig_filename, 'other.txt') + self.assertEqual(new.compress_type, zipfile.ZIP_DEFLATED) + # Not replaced attributes are inherited from the original object. + self.assertEqual(new.date_time, (2020, 1, 2, 3, 4, 5)) + self.assertEqual(new.file_size, 100) + self.assertEqual(new.CRC, 42) + # The original object is left unchanged. + self.assertEqual(zinfo.filename, 'name.txt') + self.assertEqual(zinfo.compress_type, zipfile.ZIP_STORED) + + new = copy.replace(zinfo, date_time=(1980, 1, 1, 0, 0, 0)) + self.assertEqual(new.date_time, (1980, 1, 1, 0, 0, 0)) + with self.assertRaises(ValueError): + copy.replace(zinfo, date_time=(1979, 12, 31, 0, 0, 0)) + with self.assertRaisesRegex(TypeError, "'filenam'"): + copy.replace(zinfo, filenam='other.txt') + with self.assertRaisesRegex(TypeError, "'orig_filename'"): + copy.replace(zinfo, orig_filename='other.txt') + + def test_replace_unset_attributes(self): + # header_offset and CRC are only set when the object is written + # to or read from an archive. + zinfo = zipfile.ZipInfo('name.txt') + new = copy.replace(zinfo, file_size=5) + self.assertEqual(new.file_size, 5) + self.assertFalse(hasattr(new, 'header_offset')) + self.assertFalse(hasattr(new, 'CRC')) + class CommandLineTest(unittest.TestCase): diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e87..ba2135d36989fc3 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -4,6 +4,7 @@ XXX references to utf-8 need further investigation. """ import binascii +import copy import io import os import shutil @@ -501,6 +502,22 @@ def _compresslevel(self): def _compresslevel(self, value): self.compress_level = value + def __replace__(self, /, **changes): + unexpected = changes.keys() - _zipinfo_attributes + if unexpected: + raise TypeError(f'__replace__() got an unexpected keyword ' + f'argument {min(unexpected)!r}') + new = copy.copy(self) + if 'filename' in changes: + filename = changes.pop('filename') + new.orig_filename = filename + new.filename = _sanitize_filename(filename) + if changes.get('date_time', (1980,))[0] < 1980: + raise ValueError('ZIP does not support timestamps before 1980') + for name, value in changes.items(): + setattr(new, name, value) + return new + def __repr__(self): result = ['<%s filename=%r' % (self.__class__.__name__, self.filename)] if self.compress_type != ZIP_STORED: @@ -705,6 +722,11 @@ def is_dir(self): return False +# Attributes which can be replaced by ZipInfo.__replace__(). +_zipinfo_attributes = frozenset(ZipInfo.__slots__) - { + 'orig_filename', '_raw_time', '_end_offset'} + + # ZIP encryption uses the CRC32 one-byte primitive for scrambling some # internal keys. We noticed that a direct implementation is faster than # relying on binascii.crc32(). diff --git a/Misc/NEWS.d/next/Library/2026-08-01-19-00-00.gh-issue-155039.Zp1nFo.rst b/Misc/NEWS.d/next/Library/2026-08-01-19-00-00.gh-issue-155039.Zp1nFo.rst new file mode 100644 index 000000000000000..1d76dc786fe3a29 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-01-19-00-00.gh-issue-155039.Zp1nFo.rst @@ -0,0 +1 @@ +:class:`zipfile.ZipInfo` objects now support :func:`copy.replace`. From 725e1c7d77edf4af0b3e10fa67089ebd2cca79be Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 1 Aug 2026 21:48:09 +0300 Subject: [PATCH 2/2] Say "support for" instead of "support of" Co-Authored-By: Claude Opus 5 (1M context) --- 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 dd7476e8035d6b0..25c95af577abc0e 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -1045,7 +1045,7 @@ Instances have the following methods and attributes: which returns a copy of the object with the specified attributes replaced. .. versionchanged:: next - Added support of :func:`copy.replace`. + Added support for :func:`copy.replace`. .. _zipfile-commandline: