Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
65 changes: 65 additions & 0 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ The module defines the following items:
:ref:`zipfile-objects` for constructor details.


.. class:: Path
:noindex:

A pathlib-compatible wrapper for zip files. See section
:ref:`path-objects` for details.

.. versionadded:: 3.8


.. class:: PyZipFile
:noindex:

Expand Down Expand Up @@ -456,6 +465,62 @@ The following data attributes are also available:
truncated.


.. _path-objects:

Path Objects
------------

.. class:: Path(root, at='')

Construct a Path object from a root zipfile (which may be a
Comment thread
jaraco marked this conversation as resolved.
Resolved
:class:`ZipFile` instance or ``file`` suitable for passing to
the :class:`ZipFile` constructor).

Path objects expose the following features of :module:`pathlib.Path`
objects:

.. attribute:: Path.name

The final path component.

.. operator:: Path.__truediv__

Exposes the slash operator to reference children.

.. method:: Path.open(*, **)

Invoke :method:`ZipFile.open` on the current path. Accepts
the same arguments as :method:`ZipFile.open`.

.. method:: Path.listdir()

Enumerate the children of the current directory.

.. method:: Path.is_dir()

Return True if the current context references a directory.
Comment thread
jaraco marked this conversation as resolved.
Resolved

.. method:: Path.is_file()

Return True if the current context references a file.

.. method:: Path.exists()

Return True if the current context references a file or
directory in the zip file.

.. method:: Path.read_text(*, **)

Read the current file as unicode text. Positional and
keyword arguments are passed through to
:class:`io.TextIOWrapper` (except ``buffer``, which is
implied by the context).

.. method:: Path.read_bytes()

Read the current file as bytes.


.. _pyzipfile-objects:

PyZipFile Objects
Expand Down
118 changes: 118 additions & 0 deletions Lib/test/test_zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import struct
import zipfile
import unittest
import tempfile
import shutil
Comment thread
jaraco marked this conversation as resolved.
Resolved


from tempfile import TemporaryFile
Expand Down Expand Up @@ -2392,5 +2394,121 @@ def test_extract_command(self):
with open(path, 'rb') as f:
self.assertEqual(f.read(), zf.read(zi))


consume = tuple
Comment thread
jaraco marked this conversation as resolved.
Resolved


def add_dirs(zipfile):
"""
Given a writable zipfile, inject directory entries for
any directories implied by the presence of children.
"""
names = zipfile.namelist()
consume(
zipfile.writestr(name + "/", b"")
for name in map(posixpath.dirname, names)
if name and name + "/" not in names
)
return zipfile


def build_abcde_files():
"""
Create a zip file with this structure:

.
├── a.txt
└── b
├── c.txt
└── d
└── e.txt
"""
data = io.BytesIO()
zf = zipfile.ZipFile(data, "w")
zf.writestr("a.txt", b"content of a")
zf.writestr("b/c.txt", b"content of c")
zf.writestr("b/d/e.txt", b"content of e")
zf.filename = "abcde.zip"
return zf


@contextlib.contextmanager
def tempdir():
Comment thread
jaraco marked this conversation as resolved.
Resolved
tmpdir = tempfile.mkdtemp()
try:
yield pathlib.Path(tmpdir)
finally:
shutil.rmtree(tmpdir)


class TestPath(unittest.TestCase):
def setUp(self):
self.fixtures = contextlib.ExitStack()
self.addCleanup(self.fixtures.close)

def zipfile_abcde(self):
with self.subTest():
yield build_abcde_files()
with self.subTest():
yield add_dirs(build_abcde_files())

def zipfile_ondisk(self):
tmpdir = self.fixtures.enter_context(tempdir())
for zipfile_abcde in self.zipfile_abcde():
buffer = zipfile_abcde.fp
zipfile_abcde.close()
path = tmpdir / zipfile_abcde.filename
with path.open("wb") as strm:
strm.write(buffer.getvalue())
yield path

def test_iterdir_istype(self):
for zipfile_abcde in self.zipfile_abcde():
root = zipfile.Path(zipfile_abcde)
assert root.is_dir()
a, b = root.iterdir()
assert a.is_file()
assert b.is_dir()
c, d = b.iterdir()
assert c.is_file()
e, = d.iterdir()
assert e.is_file()

def test_open(self):
for zipfile_abcde in self.zipfile_abcde():
root = zipfile.Path(zipfile_abcde)
a, b = root.iterdir()
with a.open() as strm:
data = strm.read()
assert data == b"content of a"

def test_read(self):
for zipfile_abcde in self.zipfile_abcde():
root = zipfile.Path(zipfile_abcde)
a, b = root.iterdir()
assert a.read_text() == "content of a"
assert a.read_bytes() == b"content of a"

def test_traverse_truediv(self):
for zipfile_abcde in self.zipfile_abcde():
root = zipfile.Path(zipfile_abcde)
a = root / "a"
assert a.is_file()
e = root / "b" / "d" / "e.txt"
assert e.read_text() == "content of e"

def test_pathlike_construction(self):
"""
zipfile.Path should be constructable from a path-like object
"""
for zipfile_ondisk in self.zipfile_ondisk():
pathlike = pathlib.Path(str(zipfile_ondisk))
zipfile.Path(pathlike)

def test_traverse_pathlike(self):
for zipfile_abcde in self.zipfile_abcde():
root = zipfile.Path(zipfile_abcde)
root / pathlib.Path("a")

if __name__ == "__main__":
unittest.main()
134 changes: 134 additions & 0 deletions Lib/zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import struct
import binascii
import threading
import posixpath
import functools
Comment thread
jaraco marked this conversation as resolved.
Resolved

try:
import zlib # We may need its compression method
Expand Down Expand Up @@ -2102,6 +2104,138 @@ def _compile(file, optimize=-1):
return (fname, archivename)


class Path:
"""
A pathlib-compatible interface for zip files.

Consider a zip file with this structure::

.
├── a.txt
└── b
├── c.txt
└── d
└── e.txt

>>> data = io.BytesIO()
>>> zf = ZipFile(data, 'w')
>>> zf.writestr('a.txt', 'content of a')
>>> zf.writestr('b/c.txt', 'content of c')
>>> zf.writestr('b/d/e.txt', 'content of e')
>>> zf.filename = 'abcde.zip'

Path accepts the zipfile object itself or a filename

>>> root = Path(zf)

From there, several path operations are available.

Directory iteration (including the zip file itself):

>>> a, b = root.iterdir()
>>> a
Path('abcde.zip', 'a.txt')
>>> b
Path('abcde.zip', 'b/')

name property:

>>> b.name
'b'

join with divide operator:

>>> c = b / 'c.txt'
>>> c
Path('abcde.zip', 'b/c.txt')
>>> c.name
'c.txt'

Read text:

>>> c.read_text()
'content of c'

existence:

>>> c.exists()
True
>>> (b / 'missing.txt').exists()
False

Coersion to string:

>>> str(c)
'abcde.zip/b/c.txt'
"""

__repr = "{self.__class__.__name__}({self.root.filename!r}, {self.at!r})"
Comment thread
jaraco marked this conversation as resolved.
Resolved

def __init__(self, root, at=""):
self.root = root if isinstance(root, ZipFile) else ZipFile(root)
self.at = at

@property
def open(self):
return functools.partial(self.root.open, self.at)

@property
def name(self):
return posixpath.basename(self.at.rstrip("/"))

def read_text(self, *args, **kwargs):
with self.open() as strm:
return io.TextIOWrapper(strm, *args, **kwargs).read()

def read_bytes(self):
with self.open() as strm:
return strm.read()

def _is_child(self, path):
return posixpath.dirname(path.at.rstrip("/")) == self.at.rstrip("/")

def _next(self, at):
return Path(self.root, at)

def is_dir(self):
return not self.at or self.at.endswith("/")

def is_file(self):
return not self.is_dir()

def exists(self):
return self.at in self._names()

def iterdir(self):
if not self.is_dir():
raise ValueError("Can't listdir a file")
subs = map(self._next, self._names())
return filter(self._is_child, subs)

def __str__(self):
return posixpath.join(self.root.filename, self.at)

def __repr__(self):
return self.__repr.format(self=self)

def __truediv__(self, add):
next = posixpath.join(self.at, add)
next_dir = posixpath.join(self.at, add, "")
names = self._names()
return self._next(next_dir if next not in names and next_dir in names else next)

@staticmethod
def _add_implied_dirs(names):
return names + [
name + "/"
for name in map(posixpath.dirname, names)
if name and name + "/" not in names
]

def _names(self):
return self._add_implied_dirs(self.root.namelist())


def main(args=None):
import argparse

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introducing ``zipfile.Path``, a pathlib-compatible wrapper for traversing zip files.