Skip to content
Open
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
4 changes: 4 additions & 0 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ ZipFile objects
Added support for specifying member name encoding for reading
metadata in the zipfile's directory and file headers.

.. versionchanged:: next
Deleting a :class:`zipfile.ZipFile` which contains unwritten data before
it is closed now emits a :exc:`ResourceWarning`. Use as a
:term:`context manager` or call :meth:`~zipfile.ZipFile.close` explicitly.

.. method:: ZipFile.close()

Expand Down
11 changes: 9 additions & 2 deletions Lib/test/test_zipfile/_path/_test_params.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import functools
import types
from contextlib import AbstractContextManager, ExitStack

from ._itertools import always_iterable

Expand All @@ -9,6 +10,8 @@ def parameterize(names, value_groups):
Decorate a test method to run it as a set of subtests.

Modeled after pytest.parametrize.

Context Manager types are entered and exited.
"""

def decorator(func):
Expand All @@ -17,8 +20,12 @@ def wrapped(self):
for values in value_groups:
resolved = map(Invoked.eval, always_iterable(values))
params = dict(zip(always_iterable(names), resolved))
with self.subTest(**params):
func(self, **params)
with ExitStack() as fixtures:
for value in params.values():
if isinstance(value, AbstractContextManager):
fixtures.enter_context(value)
with self.subTest(**params):
func(self, **params)

return wrapped

Expand Down
25 changes: 11 additions & 14 deletions Lib/test/test_zipfile/_path/test_path.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import contextlib
import io
import itertools
import pathlib
Expand Down Expand Up @@ -83,12 +82,8 @@ def build_alpharep_fixture():


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

def zipfile_ondisk(self, alpharep):
tmpdir = pathlib.Path(self.fixtures.enter_context(temp_dir()))
tmpdir = pathlib.Path(self.enterContext(temp_dir()))
buffer = alpharep.fp
alpharep.close()
path = tmpdir / alpharep.filename
Expand Down Expand Up @@ -145,7 +140,7 @@ def test_open(self, alpharep):

def test_open_encoding_utf16(self):
in_memory_file = io.BytesIO()
zf = zipfile.ZipFile(in_memory_file, "w")
zf = self.enterContext(zipfile.ZipFile(in_memory_file, "w"))
zf.writestr("path/16.txt", "This was utf-16".encode("utf-16"))
zf.filename = "test_open_utf16.zip"
root = zipfile.Path(zf)
Expand All @@ -160,7 +155,7 @@ def test_open_encoding_utf16(self):

def test_open_encoding_errors(self):
in_memory_file = io.BytesIO()
zf = zipfile.ZipFile(in_memory_file, "w")
zf = self.enterContext(zipfile.ZipFile(in_memory_file, "w"))
zf.writestr("path/bad-utf8.bin", b"invalid utf-8: \xff\xff.")
zf.filename = "test_read_text_encoding_errors.zip"
root = zipfile.Path(zf)
Expand Down Expand Up @@ -204,7 +199,8 @@ def test_open_write(self):
If the zipfile is open for write, it should be possible to
write bytes or text to it.
"""
zf = zipfile.Path(zipfile.ZipFile(io.BytesIO(), mode='w'))
zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), mode='w'))
zf = zipfile.Path(zip_file)
with zf.joinpath('file.bin').open('wb') as strm:
strm.write(b'binary contents')
with zf.joinpath('file.txt').open('w', encoding="utf-8") as strm:
Expand Down Expand Up @@ -319,7 +315,7 @@ def test_mutability(self, alpharep):
def huge_zipfile(self):
"""Create a read-only zipfile with a huge number of entries."""
strm = io.BytesIO()
zf = zipfile.ZipFile(strm, "w")
zf = self.enterContext(zipfile.ZipFile(strm, "w"))
for entry in map(str, range(self.HUGE_ZIPFILE_NUM_ENTRIES)):
zf.writestr(entry, entry)
zf.mode = 'r'
Expand Down Expand Up @@ -530,7 +526,8 @@ def test_glob_chars(self, alpharep):
]

def test_glob_empty(self):
root = zipfile.Path(zipfile.ZipFile(io.BytesIO(), 'w'))
zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), 'w'))
root = zipfile.Path(zip_file)
with self.assertRaises(ValueError):
root.glob('')

Expand Down Expand Up @@ -614,7 +611,7 @@ def test_malformed_paths(self):
Paths with dots are treated like regular files.
"""
data = io.BytesIO()
zf = zipfile.ZipFile(data, "w")
zf = self.enterContext(zipfile.ZipFile(data, "w"))
zf.writestr("/one-slash.txt", b"content")
zf.writestr("//two-slash.txt", b"content")
zf.writestr("../parent.txt", b"content")
Expand All @@ -632,7 +629,7 @@ def test_unsupported_names(self):
in the zip file.
"""
data = io.BytesIO()
zf = zipfile.ZipFile(data, "w")
zf = self.enterContext(zipfile.ZipFile(data, "w"))
zf.writestr("path?", b"content")
zf.writestr("V: NMS.flac", b"fLaC...")
zf.filename = ''
Expand All @@ -647,7 +644,7 @@ def test_backslash_not_separator(self):
In a zip file, backslashes are not separators.
"""
data = io.BytesIO()
zf = zipfile.ZipFile(data, "w")
zf = self.enterContext(zipfile.ZipFile(data, "w"))
zf.writestr(DirtyZipInfo("foo\\bar")._for_archive(zf), b"content")
zf.filename = ''
root = zipfile.Path(zf)
Expand Down
33 changes: 28 additions & 5 deletions Lib/test/test_zipfile/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@
from test.support import (
findfile, requires_zlib, requires_bz2, requires_lzma,
requires_zstd, captured_stdout, captured_stderr, requires_subprocess,
cpython_only
cpython_only, gc_collect
)
from test.support.os_helper import (
TESTFN, unlink, rmtree, temp_dir, temp_cwd, fd_count, FakePath
)
from test.support.import_helper import ensure_lazy_imports
from test.support.warnings_helper import check_no_resource_warning


TESTFN2 = TESTFN + "2"
Expand Down Expand Up @@ -4051,6 +4052,28 @@ def test_close_on_exception(self):
except zipfile.BadZipFile:
self.assertIsNone(zipfp2.fp, 'zipfp is not closed')

def test_garbage_collection(self):
# gh-81954: Warn if a writable zipfile is closed by GC.
with self.assertWarns(ResourceWarning):
zipfile.ZipFile(io.BytesIO(), "w")
gc_collect()

# Only warn if there is possible data loss.
# Properly closed via context manager.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("f.txt", b"data")

with check_no_resource_warning(self):
# Read mode: No possible data loss.
zipfile.ZipFile(buf, "r")

# Write with manual explicit close: No pending data.
zf = zipfile.ZipFile(io.BytesIO(), "w")
zf.writestr("f.txt", b"data")
zf.close()
del zf

def test_unsupported_version(self):
# File has an extract_version of 120
data = (b'PK\x03\x04x\x00\x00\x00\x00\x00!p\xa1@\x00\x00\x00\x00\x00\x00'
Expand Down Expand Up @@ -5503,10 +5526,10 @@ def test_root_folder_in_zipfile(self):
the zip file, this is a strange behavior, but we should support it.
"""
in_memory_file = io.BytesIO()
zf = zipfile.ZipFile(in_memory_file, "w")
zf.mkdir('/')
zf.writestr('./a.txt', 'aaa')
zf.extractall(TESTFN2)
with zipfile.ZipFile(in_memory_file, "w") as zf:
zf.mkdir('/')
zf.writestr('./a.txt', 'aaa')
zf.extractall(TESTFN2)

def tearDown(self):
rmtree(TESTFN2)
Expand Down
8 changes: 8 additions & 0 deletions Lib/zipfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys
import threading
import time
lazy import warnings

try:
import zlib # We may need its compression method
Expand Down Expand Up @@ -2616,6 +2617,13 @@ def mkdir(self, zinfo_or_directory_name, mode=511):

def __del__(self):
"""Call the "close()" method in case the user forgot."""
# gh-81954: Warn if ZipFile is implicitly closed with unwritten end
# records. GC cleanup order is non-deterministic and can result in data
# loss.
if (self.fp is not None and self.mode in ('w', 'x', 'a')
and self._didModify):
warnings.warn(f"unclosed ZipFile {self!r}",
ResourceWarning, source=self, stacklevel=2)
self.close()

def close(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Deleting a :class:`zipfile.ZipFile` which contains unwritten data before it
is closed now emits a :exc:`ResourceWarning`. Use as a :term:`context manager`
or call :meth:`~zipfile.ZipFile.close` explicitly.
Loading