Skip to content

bpo-28231: The zipfile module now accepts path-like objects for external paths. #511

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
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
24 changes: 22 additions & 2 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ ZipFile Objects

.. class:: ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True)

Open a ZIP file, where *file* can be either a path to a file (a string) or a
file-like object. The *mode* parameter should be ``'r'`` to read an existing
Open a ZIP file, where *file* can be a path to a file (a string), a
file-like object or a :term:`path-like object`.
Copy link
Member

@zhangyangyu zhangyangyu Mar 8, 2017

Choose a reason for hiding this comment

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

file-like object is a term. So we can use :term:`file-like object`.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agree. But this is separate issue. There are a lot of mentions of file and file-like objects in the documentation.

The *mode* parameter should be ``'r'`` to read an existing
file, ``'w'`` to truncate and write a new file, ``'a'`` to append to an
existing file, or ``'x'`` to exclusively create and write a new file.
If *mode* is ``'x'`` and *file* refers to an existing file,
Expand Down Expand Up @@ -183,6 +184,9 @@ ZipFile Objects
Previously, a plain :exc:`RuntimeError` was raised for unrecognized
compression values.

.. versionchanged:: 3.6.2
The *file* parameter accepts a :term:`path-like object`.


.. method:: ZipFile.close()

Expand Down Expand Up @@ -284,6 +288,9 @@ ZipFile Objects
Calling :meth:`extract` on a closed ZipFile will raise a
:exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised.

.. versionchanged:: 3.6.2
The *path* parameter accepts a :term:`path-like object`.


.. method:: ZipFile.extractall(path=None, members=None, pwd=None)

Expand All @@ -304,6 +311,9 @@ ZipFile Objects
Calling :meth:`extractall` on a closed ZipFile will raise a
:exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised.

.. versionchanged:: 3.6.2
The *path* parameter accepts a :term:`path-like object`.


.. method:: ZipFile.printdir()

Expand Down Expand Up @@ -403,6 +413,9 @@ ZipFile Objects

The following data attributes are also available:

.. attribute:: ZipFile.filename

Name of the ZIP file.

.. attribute:: ZipFile.debug

Expand Down Expand Up @@ -488,6 +501,9 @@ The :class:`PyZipFile` constructor takes the same parameters as the
.. versionadded:: 3.4
The *filterfunc* parameter.

.. versionchanged:: 3.6.2
The *pathname* parameter accepts a :term:`path-like object`.


.. _zipinfo-objects:

Expand All @@ -514,6 +530,10 @@ file:

.. versionadded:: 3.6

.. versionchanged:: 3.6.2
The *filename* parameter accepts a :term:`path-like object`.


Instances have the following methods and attributes:

.. method:: ZipInfo.is_dir()
Expand Down
117 changes: 102 additions & 15 deletions Lib/test/test_zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import io
import os
import importlib.util
import pathlib
import posixpath
import time
import struct
Expand All @@ -13,7 +14,7 @@
from random import randint, random, getrandbits

from test.support import script_helper
from test.support import (TESTFN, findfile, unlink, rmtree, temp_dir,
from test.support import (TESTFN, findfile, unlink, rmtree, temp_dir, temp_cwd,
requires_zlib, requires_bz2, requires_lzma,
captured_stdout, check_warnings)

Expand Down Expand Up @@ -148,6 +149,12 @@ def test_open(self):
for f in get_files(self):
self.zip_open_test(f, self.compression)

def test_open_with_pathlike(self):
path = pathlib.Path(TESTFN2)
self.zip_open_test(path, self.compression)
with zipfile.ZipFile(path, "r", self.compression) as zipfp:
self.assertIsInstance(zipfp.filename, str)

def zip_random_open_test(self, f, compression):
self.make_test_archive(f, compression)

Expand Down Expand Up @@ -906,49 +913,107 @@ def test_write_pyfile_bad_syntax(self):
finally:
rmtree(TESTFN2)

def test_write_pathlike(self):
os.mkdir(TESTFN2)
try:
with open(os.path.join(TESTFN2, "mod1.py"), "w") as fp:
fp.write("print(42)\n")

with TemporaryFile() as t, zipfile.PyZipFile(t, "w") as zipfp:
zipfp.writepy(pathlib.Path(TESTFN2) / "mod1.py")
names = zipfp.namelist()
self.assertCompiledIn('mod1.py', names)
finally:
rmtree(TESTFN2)


class ExtractTests(unittest.TestCase):
def test_extract(self):

def make_test_file(self):
with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp:
for fpath, fdata in SMALL_TEST_DATA:
zipfp.writestr(fpath, fdata)

def test_extract(self):
with temp_cwd():
self.make_test_file()
with zipfile.ZipFile(TESTFN2, "r") as zipfp:
for fpath, fdata in SMALL_TEST_DATA:
writtenfile = zipfp.extract(fpath)

# make sure it was written to the right place
correctfile = os.path.join(os.getcwd(), fpath)
correctfile = os.path.normpath(correctfile)

self.assertEqual(writtenfile, correctfile)

# make sure correct data is in correct file
with open(writtenfile, "rb") as f:
self.assertEqual(fdata.encode(), f.read())

unlink(writtenfile)

def _test_extract_with_target(self, target):
self.make_test_file()
with zipfile.ZipFile(TESTFN2, "r") as zipfp:
for fpath, fdata in SMALL_TEST_DATA:
writtenfile = zipfp.extract(fpath)
writtenfile = zipfp.extract(fpath, target)

# make sure it was written to the right place
correctfile = os.path.join(os.getcwd(), fpath)
correctfile = os.path.join(target, fpath)
correctfile = os.path.normpath(correctfile)

self.assertEqual(writtenfile, correctfile)
self.assertTrue(os.path.samefile(writtenfile, correctfile), (writtenfile, target))

# make sure correct data is in correct file
with open(writtenfile, "rb") as f:
self.assertEqual(fdata.encode(), f.read())

unlink(writtenfile)

# remove the test file subdirectories
rmtree(os.path.join(os.getcwd(), 'ziptest2dir'))
unlink(TESTFN2)

def test_extract_with_target(self):
with temp_dir() as extdir:
self._test_extract_with_target(extdir)

def test_extract_with_target_pathlike(self):
with temp_dir() as extdir:
self._test_extract_with_target(pathlib.Path(extdir))

def test_extract_all(self):
with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp:
for fpath, fdata in SMALL_TEST_DATA:
zipfp.writestr(fpath, fdata)
with temp_cwd():
self.make_test_file()
with zipfile.ZipFile(TESTFN2, "r") as zipfp:
zipfp.extractall()
for fpath, fdata in SMALL_TEST_DATA:
outfile = os.path.join(os.getcwd(), fpath)

with open(outfile, "rb") as f:
self.assertEqual(fdata.encode(), f.read())

unlink(outfile)

def _test_extract_all_with_target(self, target):
self.make_test_file()
with zipfile.ZipFile(TESTFN2, "r") as zipfp:
zipfp.extractall()
zipfp.extractall(target)
for fpath, fdata in SMALL_TEST_DATA:
outfile = os.path.join(os.getcwd(), fpath)
outfile = os.path.join(target, fpath)

with open(outfile, "rb") as f:
self.assertEqual(fdata.encode(), f.read())

unlink(outfile)

# remove the test file subdirectories
rmtree(os.path.join(os.getcwd(), 'ziptest2dir'))
unlink(TESTFN2)

def test_extract_all_with_target(self):
with temp_dir() as extdir:
self._test_extract_all_with_target(extdir)

def test_extract_all_with_target_pathlike(self):
with temp_dir() as extdir:
self._test_extract_all_with_target(pathlib.Path(extdir))

def check_file(self, filename, content):
self.assertTrue(os.path.isfile(filename))
Expand Down Expand Up @@ -1188,6 +1253,8 @@ def test_is_zip_erroneous_file(self):
with open(TESTFN, "w") as fp:
fp.write("this is not a legal zip file\n")
self.assertFalse(zipfile.is_zipfile(TESTFN))
# - passing a path-like object
self.assertFalse(zipfile.is_zipfile(pathlib.Path(TESTFN)))
# - passing a file object
with open(TESTFN, "rb") as fp:
self.assertFalse(zipfile.is_zipfile(fp))
Expand Down Expand Up @@ -2033,6 +2100,26 @@ def test_from_file(self):
zi = zipfile.ZipInfo.from_file(__file__)
self.assertEqual(posixpath.basename(zi.filename), 'test_zipfile.py')
self.assertFalse(zi.is_dir())
self.assertEqual(zi.file_size, os.path.getsize(__file__))

def test_from_file_pathlike(self):
zi = zipfile.ZipInfo.from_file(pathlib.Path(__file__))
self.assertEqual(posixpath.basename(zi.filename), 'test_zipfile.py')
self.assertFalse(zi.is_dir())
self.assertEqual(zi.file_size, os.path.getsize(__file__))

def test_from_file_bytes(self):
zi = zipfile.ZipInfo.from_file(os.fsencode(__file__), 'test')
self.assertEqual(posixpath.basename(zi.filename), 'test')
self.assertFalse(zi.is_dir())
self.assertEqual(zi.file_size, os.path.getsize(__file__))

def test_from_file_fileno(self):
with open(__file__, 'rb') as f:
zi = zipfile.ZipInfo.from_file(f.fileno(), 'test')
self.assertEqual(posixpath.basename(zi.filename), 'test')
self.assertFalse(zi.is_dir())
self.assertEqual(zi.file_size, os.path.getsize(__file__))

def test_from_dir(self):
dirpath = os.path.dirname(os.path.abspath(__file__))
Expand Down
20 changes: 16 additions & 4 deletions Lib/zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,8 @@ def from_file(cls, filename, arcname=None):
this will be the same as filename, but without a drive letter and with
leading path separators removed).
"""
if isinstance(filename, os.PathLike):
filename = os.fspath(filename)
st = os.stat(filename)
isdir = stat.S_ISDIR(st.st_mode)
mtime = time.localtime(st.st_mtime)
Expand Down Expand Up @@ -1069,6 +1071,8 @@ def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True):
self._comment = b''

# Check if we were passed a file-like object
if isinstance(file, os.PathLike):
file = os.fspath(file)
if isinstance(file, str):
# No, it's a filename
self._filePassed = 0
Expand Down Expand Up @@ -1469,11 +1473,10 @@ def extract(self, member, path=None, pwd=None):
as possible. `member' may be a filename or a ZipInfo object. You can
specify a different directory using `path'.
"""
if not isinstance(member, ZipInfo):
member = self.getinfo(member)

if path is None:
path = os.getcwd()
else:
path = os.fspath(path)

return self._extract_member(member, path, pwd)

Expand All @@ -1486,8 +1489,13 @@ def extractall(self, path=None, members=None, pwd=None):
if members is None:
members = self.namelist()

if path is None:
path = os.getcwd()
else:
path = os.fspath(path)

for zipinfo in members:
self.extract(zipinfo, path, pwd)
self._extract_member(zipinfo, path, pwd)

@classmethod
def _sanitize_windows_name(cls, arcname, pathsep):
Expand All @@ -1508,6 +1516,9 @@ def _extract_member(self, member, targetpath, pwd):
"""Extract the ZipInfo object 'member' to a physical
file on the path targetpath.
"""
if not isinstance(member, ZipInfo):
member = self.getinfo(member)

# build the destination pathname, replacing
# forward slashes to platform specific separators.
arcname = member.filename.replace('/', os.path.sep)
Expand Down Expand Up @@ -1800,6 +1811,7 @@ def writepy(self, pathname, basename="", filterfunc=None):
If filterfunc(pathname) is given, it is called with every argument.
When it is False, the file or directory is skipped.
"""
pathname = os.fspath(pathname)
if filterfunc and not filterfunc(pathname):
if self.debug:
label = 'path' if os.path.isdir(pathname) else 'file'
Expand Down
3 changes: 3 additions & 0 deletions Misc/NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ Extension Modules
Library
-------

- bpo-28231: The zipfile module now accepts path-like objects for external
paths.

- bpo-26915: index() and count() methods of collections.abc.Sequence now
check identity before checking equality when do comparisons.

Expand Down