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.
This commit is contained in:
parent
c32f2976b8
commit
df8d4c83a6
|
@ -88,6 +88,7 @@ def _tempfile(reader, suffix=''):
|
||||||
try:
|
try:
|
||||||
os.write(fd, reader())
|
os.write(fd, reader())
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
del reader
|
||||||
yield pathlib.Path(raw_path)
|
yield pathlib.Path(raw_path)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
@ -97,14 +98,12 @@ def _tempfile(reader, suffix=''):
|
||||||
|
|
||||||
|
|
||||||
@functools.singledispatch
|
@functools.singledispatch
|
||||||
@contextlib.contextmanager
|
|
||||||
def as_file(path):
|
def as_file(path):
|
||||||
"""
|
"""
|
||||||
Given a Traversable object, return that object as a
|
Given a Traversable object, return that object as a
|
||||||
path on the local file system in a context manager.
|
path on the local file system in a context manager.
|
||||||
"""
|
"""
|
||||||
with _tempfile(path.read_bytes, suffix=path.name) as local:
|
return _tempfile(path.read_bytes, suffix=path.name)
|
||||||
yield local
|
|
||||||
|
|
||||||
|
|
||||||
@as_file.register(pathlib.Path)
|
@as_file.register(pathlib.Path)
|
||||||
|
|
|
@ -22,8 +22,8 @@ class FileReader(abc.TraversableResources):
|
||||||
class ZipReader(abc.TraversableResources):
|
class ZipReader(abc.TraversableResources):
|
||||||
def __init__(self, loader, module):
|
def __init__(self, loader, module):
|
||||||
_, _, name = module.rpartition('.')
|
_, _, name = module.rpartition('.')
|
||||||
prefix = loader.prefix.replace('\\', '/') + name + '/'
|
self.prefix = loader.prefix.replace('\\', '/') + name + '/'
|
||||||
self.path = zipfile.Path(loader.archive, prefix)
|
self.archive = loader.archive
|
||||||
|
|
||||||
def open_resource(self, resource):
|
def open_resource(self, resource):
|
||||||
try:
|
try:
|
||||||
|
@ -38,4 +38,4 @@ class ZipReader(abc.TraversableResources):
|
||||||
return target.is_file() and target.exists()
|
return target.is_file() and target.exists()
|
||||||
|
|
||||||
def files(self):
|
def files(self):
|
||||||
return self.path
|
return zipfile.Path(self.archive, self.prefix)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
|
|
||||||
from . import _common
|
from . import _common
|
||||||
from ._common import as_file, files
|
from ._common import as_file, files
|
||||||
from contextlib import contextmanager, suppress
|
from contextlib import suppress
|
||||||
from importlib.abc import ResourceLoader
|
from importlib.abc import ResourceLoader
|
||||||
from io import BytesIO, TextIOWrapper
|
from io import BytesIO, TextIOWrapper
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -10,6 +11,8 @@ from types import ModuleType
|
||||||
from typing import ContextManager, Iterable, Union
|
from typing import ContextManager, Iterable, Union
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from typing.io import BinaryIO, TextIO
|
from typing.io import BinaryIO, TextIO
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from functools import singledispatch
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -102,22 +105,26 @@ def path(
|
||||||
"""
|
"""
|
||||||
reader = _common.get_resource_reader(_common.get_package(package))
|
reader = _common.get_resource_reader(_common.get_package(package))
|
||||||
return (
|
return (
|
||||||
_path_from_reader(reader, resource)
|
_path_from_reader(reader, _common.normalize_path(resource))
|
||||||
if reader else
|
if reader else
|
||||||
_common.as_file(
|
_common.as_file(
|
||||||
_common.files(package).joinpath(_common.normalize_path(resource)))
|
_common.files(package).joinpath(_common.normalize_path(resource)))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _path_from_reader(reader, resource):
|
def _path_from_reader(reader, resource):
|
||||||
norm_resource = _common.normalize_path(resource)
|
return _path_from_resource_path(reader, resource) or \
|
||||||
|
_path_from_open_resource(reader, resource)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_from_resource_path(reader, resource):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
yield Path(reader.resource_path(norm_resource))
|
return Path(reader.resource_path(resource))
|
||||||
return
|
|
||||||
opener_reader = reader.open_resource(norm_resource)
|
|
||||||
with _common._tempfile(opener_reader.read, suffix=norm_resource) as res:
|
def _path_from_open_resource(reader, resource):
|
||||||
yield res
|
saved = io.BytesIO(reader.open_resource(resource).read())
|
||||||
|
return _common._tempfile(saved.read, suffix=resource)
|
||||||
|
|
||||||
|
|
||||||
def is_resource(package: Package, name: str) -> bool:
|
def is_resource(package: Package, name: str) -> bool:
|
||||||
|
@ -146,7 +153,7 @@ def contents(package: Package) -> Iterable[str]:
|
||||||
package = _common.get_package(package)
|
package = _common.get_package(package)
|
||||||
reader = _common.get_resource_reader(package)
|
reader = _common.get_resource_reader(package)
|
||||||
if reader is not None:
|
if reader is not None:
|
||||||
return reader.contents()
|
return _ensure_sequence(reader.contents())
|
||||||
# Is the package a namespace package? By definition, namespace packages
|
# Is the package a namespace package? By definition, namespace packages
|
||||||
# cannot have resources.
|
# cannot have resources.
|
||||||
namespace = (
|
namespace = (
|
||||||
|
@ -156,3 +163,13 @@ def contents(package: Package) -> Iterable[str]:
|
||||||
if namespace or not package.__spec__.has_location:
|
if namespace or not package.__spec__.has_location:
|
||||||
return ()
|
return ()
|
||||||
return list(item.name for item in _common.from_package(package).iterdir())
|
return list(item.name for item in _common.from_package(package).iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
@singledispatch
|
||||||
|
def _ensure_sequence(iterable):
|
||||||
|
return list(iterable)
|
||||||
|
|
||||||
|
|
||||||
|
@_ensure_sequence.register(Sequence)
|
||||||
|
def _(iterable):
|
||||||
|
return iterable
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
import uuid
|
||||||
|
import pathlib
|
||||||
|
|
||||||
from . import data01
|
from . import data01
|
||||||
from . import zipdata01, zipdata02
|
from . import zipdata01, zipdata02
|
||||||
from . import util
|
from . import util
|
||||||
from importlib import resources, import_module
|
from importlib import resources, import_module
|
||||||
|
from test.support import import_helper
|
||||||
|
from test.support.os_helper import unlink
|
||||||
|
|
||||||
|
|
||||||
class ResourceTests:
|
class ResourceTests:
|
||||||
|
@ -162,5 +166,80 @@ class NamespaceTest(unittest.TestCase):
|
||||||
'test.test_importlib.data03.namespace', 'resource1.txt')
|
'test.test_importlib.data03.namespace', 'resource1.txt')
|
||||||
|
|
||||||
|
|
||||||
|
class DeletingZipsTest(unittest.TestCase):
|
||||||
|
"""Having accessed resources in a zip file should not keep an open
|
||||||
|
reference to the zip.
|
||||||
|
"""
|
||||||
|
ZIP_MODULE = zipdata01
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
modules = import_helper.modules_setup()
|
||||||
|
self.addCleanup(import_helper.modules_cleanup, *modules)
|
||||||
|
|
||||||
|
data_path = pathlib.Path(self.ZIP_MODULE.__file__)
|
||||||
|
data_dir = data_path.parent
|
||||||
|
self.source_zip_path = data_dir / 'ziptestdata.zip'
|
||||||
|
self.zip_path = pathlib.Path('{}.zip'.format(uuid.uuid4())).absolute()
|
||||||
|
self.zip_path.write_bytes(self.source_zip_path.read_bytes())
|
||||||
|
sys.path.append(str(self.zip_path))
|
||||||
|
self.data = import_module('ziptestdata')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
sys.path.remove(str(self.zip_path))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
del sys.path_importer_cache[str(self.zip_path)]
|
||||||
|
del sys.modules[self.data.__name__]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
unlink(self.zip_path)
|
||||||
|
except OSError:
|
||||||
|
# If the test fails, this will probably fail too
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_contents_does_not_keep_open(self):
|
||||||
|
c = resources.contents('ziptestdata')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
def test_is_resource_does_not_keep_open(self):
|
||||||
|
c = resources.is_resource('ziptestdata', 'binary.file')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
def test_is_resource_failure_does_not_keep_open(self):
|
||||||
|
c = resources.is_resource('ziptestdata', 'not-present')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
@unittest.skip("Desired but not supported.")
|
||||||
|
def test_path_does_not_keep_open(self):
|
||||||
|
c = resources.path('ziptestdata', 'binary.file')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
def test_entered_path_does_not_keep_open(self):
|
||||||
|
# This is what certifi does on import to make its bundle
|
||||||
|
# available for the process duration.
|
||||||
|
c = resources.path('ziptestdata', 'binary.file').__enter__()
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
def test_read_binary_does_not_keep_open(self):
|
||||||
|
c = resources.read_binary('ziptestdata', 'binary.file')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
def test_read_text_does_not_keep_open(self):
|
||||||
|
c = resources.read_text('ziptestdata', 'utf-8.file', encoding='utf-8')
|
||||||
|
self.zip_path.unlink()
|
||||||
|
del c
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,3 @@
|
||||||
|
In ``importlib.resources``, ``.path`` method is more aggressive about
|
||||||
|
releasing handles to zipfile objects early, enabling use-cases like certifi
|
||||||
|
to leave the context open but delete the underlying zip file.
|
Loading…
Reference in New Issue