Skip to content

Commit eb83024

Browse files
committed
Add copy() to ZipFile
1 parent aec0aed commit eb83024

4 files changed

Lines changed: 384 additions & 12 deletions

File tree

Doc/library/zipfile.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,35 @@ ZipFile objects
550550
.. versionadded:: 3.11
551551

552552

553+
.. method:: ZipFile.copy(zinfo_or_arcname, new_arcname[, chunk_size])
554+
555+
Copies a member *zinfo_or_arcname* to *new_arcname* in the archive.
556+
*zinfo_or_arcname* may be the full path of the member or a :class:`ZipInfo`
557+
instance.
558+
559+
*chunk_size* may be specified to control the buffer size when copying
560+
entry data (default is 1 MiB).
561+
562+
The archive must be opened with mode ``'w'``, ``'x'`` or ``'a'``, and the
563+
underlying stream must be seekable.
564+
565+
Returns the original version of the copied :class:`ZipInfo` instance.
566+
567+
Calling :meth:`copy` on a closed ZipFile will raise a :exc:`ValueError`.
568+
569+
.. note::
570+
Renaming a member in a ZIP file requires rewriting its data, as the
571+
filename is stored within its local file entry.
572+
573+
To rename a member and reclaim the space occupied by the old entry,
574+
combine :meth:`copy`, :meth:`remove`, and :meth:`repack` like::
575+
576+
with ZipFile('spam.zip', 'a') as myzip:
577+
myzip.repack([myzip.remove(myzip.copy('old.txt', 'new.txt'))])
578+
579+
.. versionadded:: next
580+
581+
553582
.. method:: ZipFile.remove(zinfo_or_arcname)
554583

555584
Removes a member entry from the archive's central directory.

Lib/test/test_zipfile/test_core.py

Lines changed: 289 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,289 @@ def _prepare_zip_from_test_files(cls, zfname, test_files, force_zip64=False):
14821482
fh.write(data)
14831483
return list(zh.infolist())
14841484

1485+
class AbstractCopyTests(RepackHelperMixin):
1486+
@classmethod
1487+
def setUpClass(cls):
1488+
cls.test_files = cls._prepare_test_files()
1489+
1490+
def tearDown(self):
1491+
unlink(TESTFN)
1492+
1493+
def test_copy_by_name(self):
1494+
for i in range(3):
1495+
with self.subTest(i=i, filename=self.test_files[i][0]):
1496+
zinfos = self._prepare_zip_from_test_files(TESTFN, self.test_files)
1497+
with zipfile.ZipFile(TESTFN, 'a', self.compression) as zh:
1498+
zi_new = {
1499+
**ComparableZipInfo(zinfos[i]),
1500+
'filename': 'file.txt',
1501+
'orig_filename': 'file.txt',
1502+
'header_offset': zh.start_dir,
1503+
}
1504+
zh.copy(self.test_files[i][0], 'file.txt')
1505+
1506+
# check infolist
1507+
self.assertEqual(
1508+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1509+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1510+
)
1511+
1512+
# check NameToInfo cache
1513+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1514+
1515+
# check content
1516+
self.assertEqual(
1517+
zh.read(zi_new['filename']),
1518+
zh.read(zinfos[i].filename),
1519+
)
1520+
1521+
# make sure the zip file is still valid
1522+
with zipfile.ZipFile(TESTFN) as zh:
1523+
self.assertIsNone(zh.testzip())
1524+
1525+
def test_copy_by_zinfo(self):
1526+
for i in range(3):
1527+
with self.subTest(i=i, filename=self.test_files[i][0]):
1528+
zinfos = self._prepare_zip_from_test_files(TESTFN, self.test_files)
1529+
with zipfile.ZipFile(TESTFN, 'a', self.compression) as zh:
1530+
zi_new = {
1531+
**ComparableZipInfo(zinfos[i]),
1532+
'filename': 'file.txt',
1533+
'orig_filename': 'file.txt',
1534+
'header_offset': zh.start_dir,
1535+
}
1536+
zh.copy(zh.infolist()[i], 'file.txt')
1537+
1538+
# check infolist
1539+
self.assertEqual(
1540+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1541+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1542+
)
1543+
1544+
# check NameToInfo cache
1545+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1546+
1547+
# check content
1548+
self.assertEqual(
1549+
zh.read(zi_new['filename']),
1550+
zh.read(zinfos[i].filename),
1551+
)
1552+
1553+
# make sure the zip file is still valid
1554+
with zipfile.ZipFile(TESTFN) as zh:
1555+
self.assertIsNone(zh.testzip())
1556+
1557+
def test_copy_zip64(self):
1558+
for i in range(3):
1559+
with self.subTest(i=i, filename=self.test_files[i][0]):
1560+
zinfos = self._prepare_zip_from_test_files(TESTFN, self.test_files, force_zip64=True)
1561+
with zipfile.ZipFile(TESTFN, 'a', self.compression) as zh:
1562+
zi_new = {
1563+
**ComparableZipInfo(zinfos[i]),
1564+
'filename': 'file.txt',
1565+
'orig_filename': 'file.txt',
1566+
'header_offset': zh.start_dir,
1567+
}
1568+
zh.copy(self.test_files[i][0], 'file.txt')
1569+
1570+
# check infolist
1571+
self.assertEqual(
1572+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1573+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1574+
)
1575+
1576+
# check NameToInfo cache
1577+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1578+
1579+
# check content
1580+
self.assertEqual(
1581+
zh.read(zi_new['filename']),
1582+
zh.read(zinfos[i].filename),
1583+
)
1584+
1585+
# make sure the zip file is still valid
1586+
with zipfile.ZipFile(TESTFN) as zh:
1587+
self.assertIsNone(zh.testzip())
1588+
1589+
def test_copy_data_descriptor(self):
1590+
for i in range(3):
1591+
with self.subTest(i=i, filename=self.test_files[i][0]):
1592+
with open(TESTFN, 'wb') as fh:
1593+
zinfos = self._prepare_zip_from_test_files(Unseekable(fh), self.test_files)
1594+
with zipfile.ZipFile(TESTFN, 'a', self.compression) as zh:
1595+
zi_new = {
1596+
**ComparableZipInfo(zinfos[i]),
1597+
'filename': 'file.txt',
1598+
'orig_filename': 'file.txt',
1599+
'header_offset': zh.start_dir,
1600+
}
1601+
zh.copy(self.test_files[i][0], 'file.txt')
1602+
1603+
# check infolist
1604+
self.assertEqual(
1605+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1606+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1607+
)
1608+
1609+
# check NameToInfo cache
1610+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1611+
1612+
# check content
1613+
self.assertEqual(
1614+
zh.read(zi_new['filename']),
1615+
zh.read(zinfos[i].filename),
1616+
)
1617+
1618+
# make sure the zip file is still valid
1619+
with zipfile.ZipFile(TESTFN) as zh:
1620+
self.assertIsNone(zh.testzip())
1621+
1622+
def test_copy_target_exist(self):
1623+
for i in (1,):
1624+
with self.subTest(i=i, filename=self.test_files[i][0]):
1625+
zinfos = self._prepare_zip_from_test_files(TESTFN, self.test_files)
1626+
with zipfile.ZipFile(TESTFN, 'a', self.compression) as zh:
1627+
zi_new = {
1628+
**ComparableZipInfo(zinfos[i]),
1629+
'filename': 'file2.txt',
1630+
'orig_filename': 'file2.txt',
1631+
'header_offset': zh.start_dir,
1632+
}
1633+
zh.copy(self.test_files[i][0], 'file2.txt')
1634+
1635+
# check infolist
1636+
self.assertEqual(
1637+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1638+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1639+
)
1640+
1641+
# check NameToInfo cache
1642+
self.assertEqual(ComparableZipInfo(zh.getinfo('file2.txt')), zi_new)
1643+
1644+
# check content
1645+
self.assertEqual(
1646+
zh.read(zi_new['filename']),
1647+
zh.read(zinfos[i].filename),
1648+
)
1649+
1650+
# make sure the zip file is still valid
1651+
with zipfile.ZipFile(TESTFN) as zh:
1652+
self.assertIsNone(zh.testzip())
1653+
1654+
@mock.patch.object(zipfile, '_ZipRepacker')
1655+
def test_copy_closed(self, m_repack):
1656+
self._prepare_zip_from_test_files(TESTFN, self.test_files)
1657+
with zipfile.ZipFile(TESTFN, 'a') as zh:
1658+
zh.close()
1659+
with self.assertRaises(ValueError):
1660+
zh.copy(self.test_files[0][0], 'file.txt')
1661+
m_repack.assert_not_called()
1662+
1663+
@mock.patch.object(zipfile, '_ZipRepacker')
1664+
def test_copy_writing(self, m_repack):
1665+
self._prepare_zip_from_test_files(TESTFN, self.test_files)
1666+
with zipfile.ZipFile(TESTFN, 'a') as zh:
1667+
with zh.open('newfile.txt', 'w'):
1668+
with self.assertRaises(ValueError):
1669+
zh.copy(self.test_files[0][0], 'file.txt')
1670+
m_repack.assert_not_called()
1671+
1672+
@mock.patch.object(zipfile, '_ZipRepacker')
1673+
def test_copy_unseekble(self, m_repack):
1674+
with open(TESTFN, 'wb') as fh:
1675+
with zipfile.ZipFile(Unseekable(fh), 'w') as zh:
1676+
for file, data in self.test_files:
1677+
zh.writestr(file, data)
1678+
1679+
with self.assertRaises(io.UnsupportedOperation):
1680+
zh.copy(zh.infolist()[0], 'file.txt')
1681+
m_repack.assert_not_called()
1682+
1683+
def test_copy_mode_w(self):
1684+
with zipfile.ZipFile(TESTFN, 'w') as zh:
1685+
for file, data in self.test_files:
1686+
zh.writestr(file, data)
1687+
zinfos = list(zh.infolist())
1688+
1689+
zi_new = {
1690+
**ComparableZipInfo(zinfos[0]),
1691+
'filename': 'file.txt',
1692+
'orig_filename': 'file.txt',
1693+
'header_offset': zh.start_dir,
1694+
}
1695+
zh.copy(zh.infolist()[0], 'file.txt')
1696+
1697+
# check infolist
1698+
self.assertEqual(
1699+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1700+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1701+
)
1702+
1703+
# check NameToInfo cache
1704+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1705+
1706+
# check content
1707+
self.assertEqual(
1708+
zh.read(zi_new['filename']),
1709+
zh.read(zinfos[0].filename),
1710+
)
1711+
1712+
# make sure the zip file is still valid
1713+
with zipfile.ZipFile(TESTFN) as zh:
1714+
self.assertIsNone(zh.testzip())
1715+
1716+
def test_copy_mode_x(self):
1717+
with zipfile.ZipFile(TESTFN, 'x') as zh:
1718+
for file, data in self.test_files:
1719+
zh.writestr(file, data)
1720+
zinfos = list(zh.infolist())
1721+
1722+
zi_new = {
1723+
**ComparableZipInfo(zinfos[0]),
1724+
'filename': 'file.txt',
1725+
'orig_filename': 'file.txt',
1726+
'header_offset': zh.start_dir,
1727+
}
1728+
zh.copy(zh.infolist()[0], 'file.txt')
1729+
1730+
# check infolist
1731+
self.assertEqual(
1732+
[ComparableZipInfo(zi) for zi in zh.infolist()],
1733+
[*(ComparableZipInfo(zi) for zi in zinfos), zi_new],
1734+
)
1735+
1736+
# check NameToInfo cache
1737+
self.assertEqual(ComparableZipInfo(zh.getinfo('file.txt')), zi_new)
1738+
1739+
# check content
1740+
self.assertEqual(
1741+
zh.read(zi_new['filename']),
1742+
zh.read(zinfos[0].filename),
1743+
)
1744+
1745+
# make sure the zip file is still valid
1746+
with zipfile.ZipFile(TESTFN) as zh:
1747+
self.assertIsNone(zh.testzip())
1748+
1749+
class StoredCopyTests(AbstractCopyTests, unittest.TestCase):
1750+
compression = zipfile.ZIP_STORED
1751+
1752+
@requires_zlib()
1753+
class DeflateCopyTests(AbstractCopyTests, unittest.TestCase):
1754+
compression = zipfile.ZIP_DEFLATED
1755+
1756+
@requires_bz2()
1757+
class Bzip2CopyTests(AbstractCopyTests, unittest.TestCase):
1758+
compression = zipfile.ZIP_BZIP2
1759+
1760+
@requires_lzma()
1761+
class LzmaCopyTests(AbstractCopyTests, unittest.TestCase):
1762+
compression = zipfile.ZIP_LZMA
1763+
1764+
@requires_zstd()
1765+
class ZstdCopyTests(AbstractCopyTests, unittest.TestCase):
1766+
compression = zipfile.ZIP_ZSTANDARD
1767+
14851768
class AbstractRemoveTests(RepackHelperMixin):
14861769
@classmethod
14871770
def setUpClass(cls):
@@ -3432,7 +3715,7 @@ def test_calc_local_file_entry_size(self):
34323715

34333716
self.assertEqual(
34343717
repacker._calc_local_file_entry_size(fz, zi),
3435-
43,
3718+
(30, 8, 0, 5, 0),
34363719
)
34373720

34383721
# data descriptor
@@ -3444,7 +3727,7 @@ def test_calc_local_file_entry_size(self):
34443727

34453728
self.assertEqual(
34463729
repacker._calc_local_file_entry_size(fz, zi),
3447-
59,
3730+
(30, 8, 0, 5, 16),
34483731
)
34493732

34503733
# data descriptor (unsigned)
@@ -3457,7 +3740,7 @@ def test_calc_local_file_entry_size(self):
34573740

34583741
self.assertEqual(
34593742
repacker._calc_local_file_entry_size(fz, zi),
3460-
55,
3743+
(30, 8, 0, 5, 12),
34613744
)
34623745

34633746
def test_calc_local_file_entry_size_zip64(self):
@@ -3472,7 +3755,7 @@ def test_calc_local_file_entry_size_zip64(self):
34723755

34733756
self.assertEqual(
34743757
repacker._calc_local_file_entry_size(fz, zi),
3475-
63,
3758+
(30, 8, 20, 5, 0),
34763759
)
34773760

34783761
# data descriptor + zip64
@@ -3484,7 +3767,7 @@ def test_calc_local_file_entry_size_zip64(self):
34843767

34853768
self.assertEqual(
34863769
repacker._calc_local_file_entry_size(fz, zi),
3487-
87,
3770+
(30, 8, 20, 5, 24),
34883771
)
34893772

34903773
# data descriptor (unsigned) + zip64
@@ -3497,7 +3780,7 @@ def test_calc_local_file_entry_size_zip64(self):
34973780

34983781
self.assertEqual(
34993782
repacker._calc_local_file_entry_size(fz, zi),
3500-
83,
3783+
(30, 8, 20, 5, 20),
35013784
)
35023785

35033786
def test_copy_bytes(self):

0 commit comments

Comments
 (0)