Skip to content

Commit df8d4c8

Browse files
authored
bpo-41490: path and contents to aggressively close handles (#22915)
* bpo-41490: ``path`` method to aggressively close handles * Add blurb * In ZipReader.contents, eagerly evaluate the contents to release references to the zipfile. * Instead use _ensure_sequence to ensure any iterable from a reader is eagerly converted to a list if it's not already a sequence.
1 parent c32f297 commit df8d4c8

File tree

7 files changed

+114
-16
lines changed

7 files changed

+114
-16
lines changed

Lib/importlib/_common.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def _tempfile(reader, suffix=''):
8888
try:
8989
os.write(fd, reader())
9090
os.close(fd)
91+
del reader
9192
yield pathlib.Path(raw_path)
9293
finally:
9394
try:
@@ -97,14 +98,12 @@ def _tempfile(reader, suffix=''):
9798

9899

99100
@functools.singledispatch
100-
@contextlib.contextmanager
101101
def as_file(path):
102102
"""
103103
Given a Traversable object, return that object as a
104104
path on the local file system in a context manager.
105105
"""
106-
with _tempfile(path.read_bytes, suffix=path.name) as local:
107-
yield local
106+
return _tempfile(path.read_bytes, suffix=path.name)
108107

109108

110109
@as_file.register(pathlib.Path)

Lib/importlib/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 = zipfile.Path(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 zipfile.Path(self.archive, self.prefix)

Lib/importlib/resources.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import os
2+
import io
23

34
from . import _common
45
from ._common import as_file, files
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
910
from types import ModuleType
1011
from typing import ContextManager, Iterable, Union
1112
from typing import cast
1213
from typing.io import BinaryIO, TextIO
14+
from collections.abc import Sequence
15+
from functools import singledispatch
1316

1417

1518
__all__ = [
@@ -102,22 +105,26 @@ def path(
102105
"""
103106
reader = _common.get_resource_reader(_common.get_package(package))
104107
return (
105-
_path_from_reader(reader, resource)
108+
_path_from_reader(reader, _common.normalize_path(resource))
106109
if reader else
107110
_common.as_file(
108111
_common.files(package).joinpath(_common.normalize_path(resource)))
109112
)
110113

111114

112-
@contextmanager
113115
def _path_from_reader(reader, resource):
114-
norm_resource = _common.normalize_path(resource)
116+
return _path_from_resource_path(reader, resource) or \
117+
_path_from_open_resource(reader, resource)
118+
119+
120+
def _path_from_resource_path(reader, resource):
115121
with suppress(FileNotFoundError):
116-
yield Path(reader.resource_path(norm_resource))
117-
return
118-
opener_reader = reader.open_resource(norm_resource)
119-
with _common._tempfile(opener_reader.read, suffix=norm_resource) as res:
120-
yield res
122+
return Path(reader.resource_path(resource))
123+
124+
125+
def _path_from_open_resource(reader, resource):
126+
saved = io.BytesIO(reader.open_resource(resource).read())
127+
return _common._tempfile(saved.read, suffix=resource)
121128

122129

123130
def is_resource(package: Package, name: str) -> bool:
@@ -146,7 +153,7 @@ def contents(package: Package) -> Iterable[str]:
146153
package = _common.get_package(package)
147154
reader = _common.get_resource_reader(package)
148155
if reader is not None:
149-
return reader.contents()
156+
return _ensure_sequence(reader.contents())
150157
# Is the package a namespace package? By definition, namespace packages
151158
# cannot have resources.
152159
namespace = (
@@ -156,3 +163,13 @@ def contents(package: Package) -> Iterable[str]:
156163
if namespace or not package.__spec__.has_location:
157164
return ()
158165
return list(item.name for item in _common.from_package(package).iterdir())
166+
167+
168+
@singledispatch
169+
def _ensure_sequence(iterable):
170+
return list(iterable)
171+
172+
173+
@_ensure_sequence.register(Sequence)
174+
def _(iterable):
175+
return iterable

Lib/test/test_importlib/test_resource.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import sys
22
import unittest
3+
import uuid
4+
import pathlib
35

46
from . import data01
57
from . import zipdata01, zipdata02
68
from . import util
79
from importlib import resources, import_module
10+
from test.support import import_helper
11+
from test.support.os_helper import unlink
812

913

1014
class ResourceTests:
@@ -162,5 +166,80 @@ def test_namespaces_cannot_have_resources(self):
162166
'test.test_importlib.data03.namespace', 'resource1.txt')
163167

164168

169+
class DeletingZipsTest(unittest.TestCase):
170+
"""Having accessed resources in a zip file should not keep an open
171+
reference to the zip.
172+
"""
173+
ZIP_MODULE = zipdata01
174+
175+
def setUp(self):
176+
modules = import_helper.modules_setup()
177+
self.addCleanup(import_helper.modules_cleanup, *modules)
178+
179+
data_path = pathlib.Path(self.ZIP_MODULE.__file__)
180+
data_dir = data_path.parent
181+
self.source_zip_path = data_dir / 'ziptestdata.zip'
182+
self.zip_path = pathlib.Path('{}.zip'.format(uuid.uuid4())).absolute()
183+
self.zip_path.write_bytes(self.source_zip_path.read_bytes())
184+
sys.path.append(str(self.zip_path))
185+
self.data = import_module('ziptestdata')
186+
187+
def tearDown(self):
188+
try:
189+
sys.path.remove(str(self.zip_path))
190+
except ValueError:
191+
pass
192+
193+
try:
194+
del sys.path_importer_cache[str(self.zip_path)]
195+
del sys.modules[self.data.__name__]
196+
except KeyError:
197+
pass
198+
199+
try:
200+
unlink(self.zip_path)
201+
except OSError:
202+
# If the test fails, this will probably fail too
203+
pass
204+
205+
def test_contents_does_not_keep_open(self):
206+
c = resources.contents('ziptestdata')
207+
self.zip_path.unlink()
208+
del c
209+
210+
def test_is_resource_does_not_keep_open(self):
211+
c = resources.is_resource('ziptestdata', 'binary.file')
212+
self.zip_path.unlink()
213+
del c
214+
215+
def test_is_resource_failure_does_not_keep_open(self):
216+
c = resources.is_resource('ziptestdata', 'not-present')
217+
self.zip_path.unlink()
218+
del c
219+
220+
@unittest.skip("Desired but not supported.")
221+
def test_path_does_not_keep_open(self):
222+
c = resources.path('ziptestdata', 'binary.file')
223+
self.zip_path.unlink()
224+
del c
225+
226+
def test_entered_path_does_not_keep_open(self):
227+
# This is what certifi does on import to make its bundle
228+
# available for the process duration.
229+
c = resources.path('ziptestdata', 'binary.file').__enter__()
230+
self.zip_path.unlink()
231+
del c
232+
233+
def test_read_binary_does_not_keep_open(self):
234+
c = resources.read_binary('ziptestdata', 'binary.file')
235+
self.zip_path.unlink()
236+
del c
237+
238+
def test_read_text_does_not_keep_open(self):
239+
c = resources.read_text('ziptestdata', 'utf-8.file', encoding='utf-8')
240+
self.zip_path.unlink()
241+
del c
242+
243+
165244
if __name__ == '__main__':
166245
unittest.main()
255 Bytes
Binary file not shown.
Binary file not shown.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
In ``importlib.resources``, ``.path`` method is more aggressive about
2+
releasing handles to zipfile objects early, enabling use-cases like certifi
3+
to leave the context open but delete the underlying zip file.

0 commit comments

Comments
 (0)