Skip to content

bpo-4833: Add ZipFile.mkdir #32160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,17 @@ ZipFile Objects
a closed ZipFile will raise a :exc:`ValueError`. Previously,
a :exc:`RuntimeError` was raised.

.. method:: ZipFile.mkdir(zinfo_or_directory, mode=511)

Create a directory inside the archive. If *zinfo_or_directory* is a string,
a directory is created inside the archive with the mode that is specified in
the *mode* argument. If, however, *zinfo_or_directory* is
a :class:`ZipInfo` instance then the *mode* argument is ignored.

The archive must be opened with mode ``'w'``, ``'x'`` or ``'a'``.

.. versionadded:: 3.11


The following data attributes are also available:

Expand Down
53 changes: 53 additions & 0 deletions Lib/test/test_zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2637,6 +2637,59 @@ def test_writestr_dir(self):
self.assertTrue(os.path.isdir(os.path.join(target, "x")))
self.assertEqual(os.listdir(target), ["x"])

def test_mkdir(self):
with zipfile.ZipFile(TESTFN, "w") as zf:
zf.mkdir("directory")
zinfo = zf.filelist[0]
self.assertEqual(zinfo.filename, "directory/")
self.assertEqual(zinfo.external_attr, (0o40777 << 16) | 0x10)

zf.mkdir("directory2/")
zinfo = zf.filelist[1]
self.assertEqual(zinfo.filename, "directory2/")
self.assertEqual(zinfo.external_attr, (0o40777 << 16) | 0x10)

zf.mkdir("directory3", mode=0o777)
zinfo = zf.filelist[2]
self.assertEqual(zinfo.filename, "directory3/")
self.assertEqual(zinfo.external_attr, (0o40777 << 16) | 0x10)

old_zinfo = zipfile.ZipInfo("directory4/")
old_zinfo.external_attr = (0o40777 << 16) | 0x10
old_zinfo.CRC = 0
old_zinfo.file_size = 0
old_zinfo.compress_size = 0
zf.mkdir(old_zinfo)
new_zinfo = zf.filelist[3]
self.assertEqual(old_zinfo.filename, "directory4/")
self.assertEqual(old_zinfo.external_attr, new_zinfo.external_attr)

target = os.path.join(TESTFN2, "target")
os.mkdir(target)
zf.extractall(target)
self.assertEqual(set(os.listdir(target)), {"directory", "directory2", "directory3", "directory4"})

def test_create_directory_with_write(self):
with zipfile.ZipFile(TESTFN, "w") as zf:
zf.writestr(zipfile.ZipInfo('directory/'), '')

zinfo = zf.filelist[0]
self.assertEqual(zinfo.filename, "directory/")

directory = os.path.join(TESTFN2, "directory2")
os.mkdir(directory)
mode = os.stat(directory).st_mode
zf.write(directory, arcname="directory2/")
zinfo = zf.filelist[1]
self.assertEqual(zinfo.filename, "directory2/")
self.assertEqual(zinfo.external_attr, (mode << 16) | 0x10)

target = os.path.join(TESTFN2, "target")
os.mkdir(target)
zf.extractall(target)

self.assertEqual(set(os.listdir(target)), {"directory", "directory2"})

def tearDown(self):
rmtree(TESTFN2)
if os.path.exists(TESTFN):
Expand Down
53 changes: 36 additions & 17 deletions Lib/zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,7 @@ def write(self, filename, arcname=None,
if zinfo.is_dir():
zinfo.compress_size = 0
zinfo.CRC = 0
self.mkdir(zinfo)
else:
if compress_type is not None:
zinfo.compress_type = compress_type
Expand All @@ -1783,23 +1784,6 @@ def write(self, filename, arcname=None,
else:
zinfo._compresslevel = self.compresslevel

if zinfo.is_dir():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that write can no longer be used to create a directory? I suspect that will break existing code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was marked as resolved, but I do not see an answer to my question.

Copy link
Member

@merwok merwok Mar 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think write still accepts directory zinfo, now it’s handled on line 1775 by calling mkdir instead of directly inline here.
There should be a test for write + directory; if not, then this PR needs to add one!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right -- 1175 does handle that. Thanks!

A fairly quick scan of the tests doesn't reveal one using .write(some_directory/). @dignissimus please confirm such a test already exists, or add one.

with self._lock:
if self._seekable:
self.fp.seek(self.start_dir)
zinfo.header_offset = self.fp.tell() # Start of header bytes
if zinfo.compress_type == ZIP_LZMA:
# Compressed data includes an end-of-stream (EOS) marker
zinfo.flag_bits |= _MASK_COMPRESS_OPTION_1

self._writecheck(zinfo)
self._didModify = True

self.filelist.append(zinfo)
self.NameToInfo[zinfo.filename] = zinfo
self.fp.write(zinfo.FileHeader(False))
self.start_dir = self.fp.tell()
else:
with open(filename, "rb") as src, self.open(zinfo, 'w') as dest:
shutil.copyfileobj(src, dest, 1024*8)

Expand Down Expand Up @@ -1844,6 +1828,41 @@ def writestr(self, zinfo_or_arcname, data,
with self.open(zinfo, mode='w') as dest:
dest.write(data)

def mkdir(self, zinfo_or_directory_name, mode=511):
"""Creates a directory inside the zip archive."""
if isinstance(zinfo_or_directory_name, ZipInfo):
zinfo = zinfo_or_directory_name
if not zinfo.is_dir():
raise ValueError("The given ZipInfo does not describe a directory")
elif isinstance(zinfo_or_directory_name, str):
directory_name = zinfo_or_directory_name
if not directory_name.endswith("/"):
directory_name += "/"
zinfo = ZipInfo(directory_name)
zinfo.compress_size = 0
zinfo.CRC = 0
zinfo.external_attr = ((0o40000 | mode) & 0xFFFF) << 16
zinfo.file_size = 0
zinfo.external_attr |= 0x10
else:
raise TypeError("Expected type str or ZipInfo")

with self._lock:
if self._seekable:
self.fp.seek(self.start_dir)
zinfo.header_offset = self.fp.tell() # Start of header bytes
if zinfo.compress_type == ZIP_LZMA:
# Compressed data includes an end-of-stream (EOS) marker
zinfo.flag_bits |= _MASK_COMPRESS_OPTION_1

self._writecheck(zinfo)
self._didModify = True

self.filelist.append(zinfo)
self.NameToInfo[zinfo.filename] = zinfo
self.fp.write(zinfo.FileHeader(False))
self.start_dir = self.fp.tell()

def __del__(self):
"""Call the "close()" method in case the user forgot."""
self.close()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add :meth:`ZipFile.mkdir`