Skip to content

Commit f92adfb

Browse files
authored
Merge pull request #194 from python/bugfix/bpo-41490
Release handle on zipfile when generating path in ZipReader
2 parents 56072db + 522f271 commit f92adfb

File tree

10 files changed

+166
-44
lines changed

10 files changed

+166
-44
lines changed

coverage.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
branch = true
33
parallel = true
44
omit =
5-
setup*
5+
setup*
66
.tox/*/lib/python*/site-packages/*
77
*/tests/*.py
88
*/testing/*.py
99
importlib_resources/_py${OMIT}.py
1010
importlib_resources/__init__.py
1111
importlib_resources/_compat.py
1212
importlib_resources/abc.py
13+
*.zip*
1314
plugins =
1415
coverplug
1516

docs/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
importlib_resources NEWS
33
==========================
44

5+
v3.1.0
6+
======
7+
8+
* #110 and bpo-41490: ``path`` method is more aggressive about
9+
releasing handles to zipfile objects early, enabling use-cases
10+
like ``certifi`` to leave the context open but delete the underlying
11+
zip file.
12+
513
v3.0.0
614
======
715

importlib_resources/_common.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def _tempfile(reader, suffix=''):
9393
try:
9494
os.write(fd, reader())
9595
os.close(fd)
96+
del reader
9697
yield Path(raw_path)
9798
finally:
9899
try:
@@ -102,14 +103,12 @@ def _tempfile(reader, suffix=''):
102103

103104

104105
@singledispatch
105-
@contextlib.contextmanager
106106
def as_file(path):
107107
"""
108108
Given a Traversable object, return that object as a
109109
path on the local file system in a context manager.
110110
"""
111-
with _tempfile(path.read_bytes, suffix=path.name) as local:
112-
yield local
111+
return _tempfile(path.read_bytes, suffix=path.name)
113112

114113

115114
@as_file.register(Path)

importlib_resources/_py3.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import os
22
import sys
3+
import io
34

45
from . import _common
5-
from contextlib import contextmanager, suppress
6+
from contextlib import suppress
67
from importlib.abc import ResourceLoader
78
from io import BytesIO, TextIOWrapper
89
from pathlib import Path
@@ -94,22 +95,26 @@ def path(
9495
"""
9596
reader = _common.get_resource_reader(_common.get_package(package))
9697
return (
97-
_path_from_reader(reader, resource)
98+
_path_from_reader(reader, _common.normalize_path(resource))
9899
if reader else
99100
_common.as_file(
100101
_common.files(package).joinpath(_common.normalize_path(resource)))
101102
)
102103

103104

104-
@contextmanager
105105
def _path_from_reader(reader, resource):
106-
norm_resource = _common.normalize_path(resource)
106+
return _path_from_resource_path(reader, resource) or \
107+
_path_from_open_resource(reader, resource)
108+
109+
110+
def _path_from_resource_path(reader, resource):
107111
with suppress(FileNotFoundError):
108-
yield Path(reader.resource_path(norm_resource))
109-
return
110-
opener_reader = reader.open_resource(norm_resource)
111-
with _common._tempfile(opener_reader.read, suffix=norm_resource) as res:
112-
yield res
112+
return Path(reader.resource_path(resource))
113+
114+
115+
def _path_from_open_resource(reader, resource):
116+
saved = io.BytesIO(reader.open_resource(resource).read())
117+
return _common._tempfile(saved.read, suffix=resource)
113118

114119

115120
def is_resource(package: Package, name: str) -> bool:

importlib_resources/readers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ def files(self):
2222
class ZipReader(abc.TraversableResources):
2323
def __init__(self, loader, module):
2424
_, _, name = module.rpartition('.')
25-
prefix = loader.prefix.replace('\\', '/') + name + '/'
26-
self.path = ZipPath(loader.archive, prefix)
25+
self.prefix = loader.prefix.replace('\\', '/') + name + '/'
26+
self.archive = loader.archive
2727

2828
def open_resource(self, resource):
2929
try:
@@ -38,4 +38,4 @@ def is_resource(self, path):
3838
return target.is_file() and target.exists()
3939

4040
def files(self):
41-
return self.path
41+
return ZipPath(self.archive, self.prefix)

importlib_resources/tests/_compat.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
try:
2+
from test.support import import_helper # type: ignore
3+
except ImportError:
4+
try:
5+
# Python 3.9 and earlier
6+
class import_helper: # type: ignore
7+
from test.support import modules_setup, modules_cleanup
8+
except ImportError:
9+
from . import py27compat
10+
11+
class import_helper: # type: ignore
12+
modules_setup = staticmethod(py27compat.modules_setup)
13+
modules_cleanup = staticmethod(py27compat.modules_cleanup)
14+
15+
16+
try:
17+
from os import fspath # type: ignore
18+
except ImportError:
19+
# Python 3.5
20+
fspath = str # type: ignore
21+
22+
23+
try:
24+
# Python 3.10
25+
from test.support.os_helper import unlink
26+
except ImportError:
27+
from test.support import unlink as _unlink
28+
29+
def unlink(target):
30+
return _unlink(fspath(target))
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import sys
2+
3+
4+
def modules_setup():
5+
return sys.modules.copy(),
6+
7+
8+
def modules_cleanup(oldmodules):
9+
# Encoders/decoders are registered permanently within the internal
10+
# codec cache. If we destroy the corresponding modules their
11+
# globals will be set to None which will trip up the cached functions.
12+
encodings = [(k, v) for k, v in sys.modules.items()
13+
if k.startswith('encodings.')]
14+
sys.modules.clear()
15+
sys.modules.update(encodings)
16+
# XXX: This kind of problem can affect more than just encodings.
17+
# In particular extension modules (such as _ssl) don't cope
18+
# with reloading properly. Really, test modules should be cleaning
19+
# out the test specific modules they know they added (ala test_runpy)
20+
# rather than relying on this function (as test_importhooks and test_pkg
21+
# do currently). Implicitly imported *real* modules should be left alone
22+
# (see issue 10556).
23+
sys.modules.update(oldmodules)

importlib_resources/tests/test_resource.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import sys
22
import unittest
33
import importlib_resources as resources
4+
import uuid
5+
6+
from importlib_resources._compat import Path
47

58
from . import data01
69
from . import zipdata01, zipdata02
710
from . import util
811
from importlib import import_module
12+
from ._compat import import_helper, unlink
913

1014

1115
class ResourceTests:
@@ -166,5 +170,80 @@ def test_namespaces_cannot_have_resources(self):
166170
'importlib_resources.tests.data03.namespace', 'resource1.txt')
167171

168172

173+
class DeletingZipsTest(unittest.TestCase):
174+
"""Having accessed resources in a zip file should not keep an open
175+
reference to the zip.
176+
"""
177+
ZIP_MODULE = zipdata01
178+
179+
def setUp(self):
180+
modules = import_helper.modules_setup()
181+
self.addCleanup(import_helper.modules_cleanup, *modules)
182+
183+
data_path = Path(self.ZIP_MODULE.__file__)
184+
data_dir = data_path.parent
185+
self.source_zip_path = data_dir / 'ziptestdata.zip'
186+
self.zip_path = Path('{}.zip'.format(uuid.uuid4())).absolute()
187+
self.zip_path.write_bytes(self.source_zip_path.read_bytes())
188+
sys.path.append(str(self.zip_path))
189+
self.data = import_module('ziptestdata')
190+
191+
def tearDown(self):
192+
try:
193+
sys.path.remove(str(self.zip_path))
194+
except ValueError:
195+
pass
196+
197+
try:
198+
del sys.path_importer_cache[str(self.zip_path)]
199+
del sys.modules[self.data.__name__]
200+
except KeyError:
201+
pass
202+
203+
try:
204+
unlink(self.zip_path)
205+
except OSError:
206+
# If the test fails, this will probably fail too
207+
pass
208+
209+
def test_contents_does_not_keep_open(self):
210+
c = resources.contents('ziptestdata')
211+
self.zip_path.unlink()
212+
del c
213+
214+
def test_is_resource_does_not_keep_open(self):
215+
c = resources.is_resource('ziptestdata', 'binary.file')
216+
self.zip_path.unlink()
217+
del c
218+
219+
def test_is_resource_failure_does_not_keep_open(self):
220+
c = resources.is_resource('ziptestdata', 'not-present')
221+
self.zip_path.unlink()
222+
del c
223+
224+
@unittest.skip("Desired but not supported.")
225+
def test_path_does_not_keep_open(self):
226+
c = resources.path('ziptestdata', 'binary.file')
227+
self.zip_path.unlink()
228+
del c
229+
230+
def test_entered_path_does_not_keep_open(self):
231+
# This is what certifi does on import to make its bundle
232+
# available for the process duration.
233+
c = resources.path('ziptestdata', 'binary.file').__enter__()
234+
self.zip_path.unlink()
235+
del c
236+
237+
def test_read_binary_does_not_keep_open(self):
238+
c = resources.read_binary('ziptestdata', 'binary.file')
239+
self.zip_path.unlink()
240+
del c
241+
242+
def test_read_text_does_not_keep_open(self):
243+
c = resources.read_text('ziptestdata', 'utf-8.file', encoding='utf-8')
244+
self.zip_path.unlink()
245+
del c
246+
247+
169248
if __name__ == '__main__':
170249
unittest.main()

importlib_resources/tests/util.py

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,7 @@
99
from . import zipdata01
1010
from .._compat import ABC, Path, PurePath, FileNotFoundError
1111
from ..abc import ResourceReader
12-
13-
try:
14-
from test.support import modules_setup, modules_cleanup
15-
except ImportError:
16-
# Python 2.7.
17-
def modules_setup():
18-
return sys.modules.copy(),
19-
20-
def modules_cleanup(oldmodules):
21-
# Encoders/decoders are registered permanently within the internal
22-
# codec cache. If we destroy the corresponding modules their
23-
# globals will be set to None which will trip up the cached functions.
24-
encodings = [(k, v) for k, v in sys.modules.items()
25-
if k.startswith('encodings.')]
26-
sys.modules.clear()
27-
sys.modules.update(encodings)
28-
# XXX: This kind of problem can affect more than just encodings. In
29-
# particular extension modules (such as _ssl) don't cope with reloading
30-
# properly. Really, test modules should be cleaning out the test
31-
# specific modules they know they added (ala test_runpy) rather than
32-
# relying on this function (as test_importhooks and test_pkg do
33-
# currently). Implicitly imported *real* modules should be left alone
34-
# (see issue 10556).
35-
sys.modules.update(oldmodules)
12+
from ._compat import import_helper
3613

3714

3815
try:
@@ -205,8 +182,8 @@ def tearDownClass(cls):
205182
pass
206183

207184
def setUp(self):
208-
modules = modules_setup()
209-
self.addCleanup(modules_cleanup, *modules)
185+
modules = import_helper.modules_setup()
186+
self.addCleanup(import_helper.modules_cleanup, *modules)
210187

211188

212189
class ZipSetup(ZipSetupBase):

tox.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ toxworkdir={env:TOX_WORK_DIR:.tox}
77

88
[testenv]
99
commands =
10-
!cov,!diffcov: python -m unittest discover
11-
cov,diffcov: python -m coverage run {[coverage]rc} -m unittest discover {posargs}
10+
!cov,!diffcov: python -m unittest {posargs:discover}
11+
cov,diffcov: python -m coverage run {[coverage]rc} -m unittest {posargs:discover}
1212
cov,diffcov: python -m coverage combine {[coverage]rc}
1313
cov: python -m coverage html {[coverage]rc}
1414
cov: python -m coverage xml {[coverage]rc}

0 commit comments

Comments
 (0)