Commit 8606e952 authored by Serhiy Storchaka's avatar Serhiy Storchaka Committed by GitHub

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

parent c351ce6a
...@@ -132,8 +132,9 @@ ZipFile Objects ...@@ -132,8 +132,9 @@ ZipFile Objects
.. class:: ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True) .. 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 Open a ZIP file, where *file* can be a path to a file (a string), a
file-like object. The *mode* parameter should be ``'r'`` to read an existing file-like object or a :term:`path-like object`.
The *mode* parameter should be ``'r'`` to read an existing
file, ``'w'`` to truncate and write a new file, ``'a'`` to append to an 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. existing file, or ``'x'`` to exclusively create and write a new file.
If *mode* is ``'x'`` and *file* refers to an existing file, If *mode* is ``'x'`` and *file* refers to an existing file,
...@@ -183,6 +184,9 @@ ZipFile Objects ...@@ -183,6 +184,9 @@ ZipFile Objects
Previously, a plain :exc:`RuntimeError` was raised for unrecognized Previously, a plain :exc:`RuntimeError` was raised for unrecognized
compression values. compression values.
.. versionchanged:: 3.6.2
The *file* parameter accepts a :term:`path-like object`.
.. method:: ZipFile.close() .. method:: ZipFile.close()
...@@ -284,6 +288,9 @@ ZipFile Objects ...@@ -284,6 +288,9 @@ ZipFile Objects
Calling :meth:`extract` on a closed ZipFile will raise a Calling :meth:`extract` on a closed ZipFile will raise a
:exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised. :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) .. method:: ZipFile.extractall(path=None, members=None, pwd=None)
...@@ -304,6 +311,9 @@ ZipFile Objects ...@@ -304,6 +311,9 @@ ZipFile Objects
Calling :meth:`extractall` on a closed ZipFile will raise a Calling :meth:`extractall` on a closed ZipFile will raise a
:exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised. :exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised.
.. versionchanged:: 3.6.2
The *path* parameter accepts a :term:`path-like object`.
.. method:: ZipFile.printdir() .. method:: ZipFile.printdir()
...@@ -403,6 +413,9 @@ ZipFile Objects ...@@ -403,6 +413,9 @@ ZipFile Objects
The following data attributes are also available: The following data attributes are also available:
.. attribute:: ZipFile.filename
Name of the ZIP file.
.. attribute:: ZipFile.debug .. attribute:: ZipFile.debug
...@@ -488,6 +501,9 @@ The :class:`PyZipFile` constructor takes the same parameters as the ...@@ -488,6 +501,9 @@ The :class:`PyZipFile` constructor takes the same parameters as the
.. versionadded:: 3.4 .. versionadded:: 3.4
The *filterfunc* parameter. The *filterfunc* parameter.
.. versionchanged:: 3.6.2
The *pathname* parameter accepts a :term:`path-like object`.
.. _zipinfo-objects: .. _zipinfo-objects:
...@@ -514,6 +530,10 @@ file: ...@@ -514,6 +530,10 @@ file:
.. versionadded:: 3.6 .. versionadded:: 3.6
.. versionchanged:: 3.6.2
The *filename* parameter accepts a :term:`path-like object`.
Instances have the following methods and attributes: Instances have the following methods and attributes:
.. method:: ZipInfo.is_dir() .. method:: ZipInfo.is_dir()
......
...@@ -2,6 +2,7 @@ import contextlib ...@@ -2,6 +2,7 @@ import contextlib
import io import io
import os import os
import importlib.util import importlib.util
import pathlib
import posixpath import posixpath
import time import time
import struct import struct
...@@ -13,7 +14,7 @@ from tempfile import TemporaryFile ...@@ -13,7 +14,7 @@ from tempfile import TemporaryFile
from random import randint, random, getrandbits from random import randint, random, getrandbits
from test.support import script_helper 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, requires_zlib, requires_bz2, requires_lzma,
captured_stdout, check_warnings) captured_stdout, check_warnings)
...@@ -148,6 +149,12 @@ class AbstractTestsWithSourceFile: ...@@ -148,6 +149,12 @@ class AbstractTestsWithSourceFile:
for f in get_files(self): for f in get_files(self):
self.zip_open_test(f, self.compression) 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): def zip_random_open_test(self, f, compression):
self.make_test_archive(f, compression) self.make_test_archive(f, compression)
...@@ -906,22 +913,56 @@ class PyZipFileTests(unittest.TestCase): ...@@ -906,22 +913,56 @@ class PyZipFileTests(unittest.TestCase):
finally: finally:
rmtree(TESTFN2) 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): class ExtractTests(unittest.TestCase):
def test_extract(self):
def make_test_file(self):
with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp: with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp:
for fpath, fdata in SMALL_TEST_DATA: for fpath, fdata in SMALL_TEST_DATA:
zipfp.writestr(fpath, fdata) 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: with zipfile.ZipFile(TESTFN2, "r") as zipfp:
for fpath, fdata in SMALL_TEST_DATA: 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 # 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) correctfile = os.path.normpath(correctfile)
self.assertTrue(os.path.samefile(writtenfile, correctfile), (writtenfile, target))
self.assertEqual(writtenfile, correctfile)
# make sure correct data is in correct file # make sure correct data is in correct file
with open(writtenfile, "rb") as f: with open(writtenfile, "rb") as f:
...@@ -929,26 +970,50 @@ class ExtractTests(unittest.TestCase): ...@@ -929,26 +970,50 @@ class ExtractTests(unittest.TestCase):
unlink(writtenfile) unlink(writtenfile)
# remove the test file subdirectories unlink(TESTFN2)
rmtree(os.path.join(os.getcwd(), 'ziptest2dir'))
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): def test_extract_all(self):
with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp: with temp_cwd():
for fpath, fdata in SMALL_TEST_DATA: self.make_test_file()
zipfp.writestr(fpath, fdata) 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: with zipfile.ZipFile(TESTFN2, "r") as zipfp:
zipfp.extractall() zipfp.extractall(target)
for fpath, fdata in SMALL_TEST_DATA: 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: with open(outfile, "rb") as f:
self.assertEqual(fdata.encode(), f.read()) self.assertEqual(fdata.encode(), f.read())
unlink(outfile) unlink(outfile)
# remove the test file subdirectories unlink(TESTFN2)
rmtree(os.path.join(os.getcwd(), 'ziptest2dir'))
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): def check_file(self, filename, content):
self.assertTrue(os.path.isfile(filename)) self.assertTrue(os.path.isfile(filename))
...@@ -1188,6 +1253,8 @@ class OtherTests(unittest.TestCase): ...@@ -1188,6 +1253,8 @@ class OtherTests(unittest.TestCase):
with open(TESTFN, "w") as fp: with open(TESTFN, "w") as fp:
fp.write("this is not a legal zip file\n") fp.write("this is not a legal zip file\n")
self.assertFalse(zipfile.is_zipfile(TESTFN)) self.assertFalse(zipfile.is_zipfile(TESTFN))
# - passing a path-like object
self.assertFalse(zipfile.is_zipfile(pathlib.Path(TESTFN)))
# - passing a file object # - passing a file object
with open(TESTFN, "rb") as fp: with open(TESTFN, "rb") as fp:
self.assertFalse(zipfile.is_zipfile(fp)) self.assertFalse(zipfile.is_zipfile(fp))
...@@ -2033,6 +2100,26 @@ class ZipInfoTests(unittest.TestCase): ...@@ -2033,6 +2100,26 @@ class ZipInfoTests(unittest.TestCase):
zi = zipfile.ZipInfo.from_file(__file__) zi = zipfile.ZipInfo.from_file(__file__)
self.assertEqual(posixpath.basename(zi.filename), 'test_zipfile.py') self.assertEqual(posixpath.basename(zi.filename), 'test_zipfile.py')
self.assertFalse(zi.is_dir()) 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): def test_from_dir(self):
dirpath = os.path.dirname(os.path.abspath(__file__)) dirpath = os.path.dirname(os.path.abspath(__file__))
......
...@@ -478,6 +478,8 @@ class ZipInfo (object): ...@@ -478,6 +478,8 @@ class ZipInfo (object):
this will be the same as filename, but without a drive letter and with this will be the same as filename, but without a drive letter and with
leading path separators removed). leading path separators removed).
""" """
if isinstance(filename, os.PathLike):
filename = os.fspath(filename)
st = os.stat(filename) st = os.stat(filename)
isdir = stat.S_ISDIR(st.st_mode) isdir = stat.S_ISDIR(st.st_mode)
mtime = time.localtime(st.st_mtime) mtime = time.localtime(st.st_mtime)
...@@ -1069,6 +1071,8 @@ class ZipFile: ...@@ -1069,6 +1071,8 @@ class ZipFile:
self._comment = b'' self._comment = b''
# Check if we were passed a file-like object # Check if we were passed a file-like object
if isinstance(file, os.PathLike):
file = os.fspath(file)
if isinstance(file, str): if isinstance(file, str):
# No, it's a filename # No, it's a filename
self._filePassed = 0 self._filePassed = 0
...@@ -1469,11 +1473,10 @@ class ZipFile: ...@@ -1469,11 +1473,10 @@ class ZipFile:
as possible. `member' may be a filename or a ZipInfo object. You can as possible. `member' may be a filename or a ZipInfo object. You can
specify a different directory using `path'. specify a different directory using `path'.
""" """
if not isinstance(member, ZipInfo):
member = self.getinfo(member)
if path is None: if path is None:
path = os.getcwd() path = os.getcwd()
else:
path = os.fspath(path)
return self._extract_member(member, path, pwd) return self._extract_member(member, path, pwd)
...@@ -1486,8 +1489,13 @@ class ZipFile: ...@@ -1486,8 +1489,13 @@ class ZipFile:
if members is None: if members is None:
members = self.namelist() members = self.namelist()
if path is None:
path = os.getcwd()
else:
path = os.fspath(path)
for zipinfo in members: for zipinfo in members:
self.extract(zipinfo, path, pwd) self._extract_member(zipinfo, path, pwd)
@classmethod @classmethod
def _sanitize_windows_name(cls, arcname, pathsep): def _sanitize_windows_name(cls, arcname, pathsep):
...@@ -1508,6 +1516,9 @@ class ZipFile: ...@@ -1508,6 +1516,9 @@ class ZipFile:
"""Extract the ZipInfo object 'member' to a physical """Extract the ZipInfo object 'member' to a physical
file on the path targetpath. file on the path targetpath.
""" """
if not isinstance(member, ZipInfo):
member = self.getinfo(member)
# build the destination pathname, replacing # build the destination pathname, replacing
# forward slashes to platform specific separators. # forward slashes to platform specific separators.
arcname = member.filename.replace('/', os.path.sep) arcname = member.filename.replace('/', os.path.sep)
...@@ -1800,6 +1811,7 @@ class PyZipFile(ZipFile): ...@@ -1800,6 +1811,7 @@ class PyZipFile(ZipFile):
If filterfunc(pathname) is given, it is called with every argument. If filterfunc(pathname) is given, it is called with every argument.
When it is False, the file or directory is skipped. When it is False, the file or directory is skipped.
""" """
pathname = os.fspath(pathname)
if filterfunc and not filterfunc(pathname): if filterfunc and not filterfunc(pathname):
if self.debug: if self.debug:
label = 'path' if os.path.isdir(pathname) else 'file' label = 'path' if os.path.isdir(pathname) else 'file'
......
...@@ -270,6 +270,9 @@ Extension Modules ...@@ -270,6 +270,9 @@ Extension Modules
Library 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 - bpo-26915: index() and count() methods of collections.abc.Sequence now
check identity before checking equality when do comparisons. check identity before checking equality when do comparisons.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment