gh-97930: Apply changes from importlib_resources 5.10. (GH-100598)

This commit is contained in:
Jason R. Coombs 2023-01-01 11:07:32 -05:00 committed by GitHub
parent ba1342ce99
commit 447d061bc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 268 additions and 90 deletions

View File

@ -14,12 +14,13 @@ This module leverages Python's import system to provide access to *resources*
within *packages*. within *packages*.
"Resources" are file-like resources associated with a module or package in "Resources" are file-like resources associated with a module or package in
Python. The resources may be contained directly in a package or within a Python. The resources may be contained directly in a package, within a
subdirectory contained in that package. Resources may be text or binary. As a subdirectory contained in that package, or adjacent to modules outside a
result, Python module sources (.py) of a package and compilation artifacts package. Resources may be text or binary. As a result, Python module sources
(pycache) are technically de-facto resources of that package. In practice, (.py) of a package and compilation artifacts (pycache) are technically
however, resources are primarily those non-Python artifacts exposed de-facto resources of that package. In practice, however, resources are
specifically by the package author. primarily those non-Python artifacts exposed specifically by the package
author.
Resources can be opened or read in either binary or text mode. Resources can be opened or read in either binary or text mode.
@ -49,27 +50,35 @@ for example, a package and its resources can be imported from a zip file using
``get_resource_reader(fullname)`` method as specified by ``get_resource_reader(fullname)`` method as specified by
:class:`importlib.resources.abc.ResourceReader`. :class:`importlib.resources.abc.ResourceReader`.
.. data:: Package .. data:: Anchor
Whenever a function accepts a ``Package`` argument, you can pass in Represents an anchor for resources, either a :class:`module object
either a :class:`module object <types.ModuleType>` or a module name <types.ModuleType>` or a module name as a string. Defined as
as a string. You can only pass module objects whose ``Union[str, ModuleType]``.
``__spec__.submodule_search_locations`` is not ``None``.
The ``Package`` type is defined as ``Union[str, ModuleType]``. .. function:: files(anchor: Optional[Anchor] = None)
.. function:: files(package)
Returns a :class:`~importlib.resources.abc.Traversable` object Returns a :class:`~importlib.resources.abc.Traversable` object
representing the resource container for the package (think directory) representing the resource container (think directory) and its resources
and its resources (think files). A Traversable may contain other (think files). A Traversable may contain other containers (think
containers (think subdirectories). subdirectories).
*package* is either a name or a module object which conforms to the *anchor* is an optional :data:`Anchor`. If the anchor is a
:data:`Package` requirements. package, resources are resolved from that package. If a module,
resources are resolved adjacent to that module (in the same package
or the package root). If the anchor is omitted, the caller's module
is used.
.. versionadded:: 3.9 .. versionadded:: 3.9
.. versionchanged:: 3.12
"package" parameter was renamed to "anchor". "anchor" can now
be a non-package module and if omitted will default to the caller's
module. "package" is still accepted for compatibility but will raise
a DeprecationWarning. Consider passing the anchor positionally or
using ``importlib_resources >= 5.10`` for a compatible interface
on older Pythons.
.. function:: as_file(traversable) .. function:: as_file(traversable)
Given a :class:`~importlib.resources.abc.Traversable` object representing Given a :class:`~importlib.resources.abc.Traversable` object representing
@ -86,6 +95,7 @@ for example, a package and its resources can be imported from a zip file using
.. versionadded:: 3.9 .. versionadded:: 3.9
Deprecated functions Deprecated functions
-------------------- --------------------
@ -94,6 +104,18 @@ scheduled for removal in a future version of Python.
The main drawback of these functions is that they do not support The main drawback of these functions is that they do not support
directories: they assume all resources are located directly within a *package*. directories: they assume all resources are located directly within a *package*.
.. data:: Package
Whenever a function accepts a ``Package`` argument, you can pass in
either a :class:`module object <types.ModuleType>` or a module name
as a string. You can only pass module objects whose
``__spec__.submodule_search_locations`` is not ``None``.
The ``Package`` type is defined as ``Union[str, ModuleType]``.
.. deprecated:: 3.12
.. data:: Resource .. data:: Resource
For *resource* arguments of the functions below, you can pass in For *resource* arguments of the functions below, you can pass in
@ -102,6 +124,7 @@ directories: they assume all resources are located directly within a *package*.
The ``Resource`` type is defined as ``Union[str, os.PathLike]``. The ``Resource`` type is defined as ``Union[str, os.PathLike]``.
.. function:: open_binary(package, resource) .. function:: open_binary(package, resource)
Open for binary reading the *resource* within *package*. Open for binary reading the *resource* within *package*.

View File

@ -5,25 +5,58 @@ import functools
import contextlib import contextlib
import types import types
import importlib import importlib
import inspect
import warnings
import itertools
from typing import Union, Optional from typing import Union, Optional, cast
from .abc import ResourceReader, Traversable from .abc import ResourceReader, Traversable
from ._adapters import wrap_spec from ._adapters import wrap_spec
Package = Union[types.ModuleType, str] Package = Union[types.ModuleType, str]
Anchor = Package
def files(package): def package_to_anchor(func):
# type: (Package) -> Traversable
""" """
Get a Traversable resource from a package Replace 'package' parameter as 'anchor' and warn about the change.
Other errors should fall through.
>>> files('a', 'b')
Traceback (most recent call last):
TypeError: files() takes from 0 to 1 positional arguments but 2 were given
""" """
return from_package(get_package(package)) undefined = object()
@functools.wraps(func)
def wrapper(anchor=undefined, package=undefined):
if package is not undefined:
if anchor is not undefined:
return func(anchor, package)
warnings.warn(
"First parameter to files is renamed to 'anchor'",
DeprecationWarning,
stacklevel=2,
)
return func(package)
elif anchor is undefined:
return func()
return func(anchor)
return wrapper
def get_resource_reader(package): @package_to_anchor
# type: (types.ModuleType) -> Optional[ResourceReader] def files(anchor: Optional[Anchor] = None) -> Traversable:
"""
Get a Traversable resource for an anchor.
"""
return from_package(resolve(anchor))
def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
""" """
Return the package's loader if it's a ResourceReader. Return the package's loader if it's a ResourceReader.
""" """
@ -39,24 +72,39 @@ def get_resource_reader(package):
return reader(spec.name) # type: ignore return reader(spec.name) # type: ignore
def resolve(cand): @functools.singledispatch
# type: (Package) -> types.ModuleType def resolve(cand: Optional[Anchor]) -> types.ModuleType:
return cand if isinstance(cand, types.ModuleType) else importlib.import_module(cand) return cast(types.ModuleType, cand)
def get_package(package): @resolve.register
# type: (Package) -> types.ModuleType def _(cand: str) -> types.ModuleType:
"""Take a package name or module object and return the module. return importlib.import_module(cand)
Raise an exception if the resolved module is not a package.
@resolve.register
def _(cand: None) -> types.ModuleType:
return resolve(_infer_caller().f_globals['__name__'])
def _infer_caller():
""" """
resolved = resolve(package) Walk the stack and find the frame of the first caller not in this module.
if wrap_spec(resolved).submodule_search_locations is None: """
raise TypeError(f'{package!r} is not a package')
return resolved def is_this_file(frame_info):
return frame_info.filename == __file__
def is_wrapper(frame_info):
return frame_info.function == 'wrapper'
not_this_file = itertools.filterfalse(is_this_file, inspect.stack())
# also exclude 'wrapper' due to singledispatch in the call stack
callers = itertools.filterfalse(is_wrapper, not_this_file)
return next(callers).frame
def from_package(package): def from_package(package: types.ModuleType):
""" """
Return a Traversable object for the given package. Return a Traversable object for the given package.

View File

@ -27,8 +27,7 @@ def deprecated(func):
return wrapper return wrapper
def normalize_path(path): def normalize_path(path: Any) -> str:
# type: (Any) -> str
"""Normalize a path by ensuring it is a string. """Normalize a path by ensuring it is a string.
If the resulting string contains path separators, an exception is raised. If the resulting string contains path separators, an exception is raised.

View File

@ -142,7 +142,8 @@ class Traversable(Protocol):
accepted by io.TextIOWrapper. accepted by io.TextIOWrapper.
""" """
@abc.abstractproperty @property
@abc.abstractmethod
def name(self) -> str: def name(self) -> str:
""" """
The base name of this object without any parent references. The base name of this object without any parent references.

View File

@ -16,31 +16,28 @@ class SimpleReader(abc.ABC):
provider. provider.
""" """
@abc.abstractproperty @property
def package(self): @abc.abstractmethod
# type: () -> str def package(self) -> str:
""" """
The name of the package for which this reader loads resources. The name of the package for which this reader loads resources.
""" """
@abc.abstractmethod @abc.abstractmethod
def children(self): def children(self) -> List['SimpleReader']:
# type: () -> List['SimpleReader']
""" """
Obtain an iterable of SimpleReader for available Obtain an iterable of SimpleReader for available
child containers (e.g. directories). child containers (e.g. directories).
""" """
@abc.abstractmethod @abc.abstractmethod
def resources(self): def resources(self) -> List[str]:
# type: () -> List[str]
""" """
Obtain available named resources for this virtual package. Obtain available named resources for this virtual package.
""" """
@abc.abstractmethod @abc.abstractmethod
def open_binary(self, resource): def open_binary(self, resource: str) -> BinaryIO:
# type: (str) -> BinaryIO
""" """
Obtain a File-like for a named resource. Obtain a File-like for a named resource.
""" """
@ -50,13 +47,35 @@ class SimpleReader(abc.ABC):
return self.package.split('.')[-1] return self.package.split('.')[-1]
class ResourceContainer(Traversable):
"""
Traversable container for a package's resources via its reader.
"""
def __init__(self, reader: SimpleReader):
self.reader = reader
def is_dir(self):
return True
def is_file(self):
return False
def iterdir(self):
files = (ResourceHandle(self, name) for name in self.reader.resources)
dirs = map(ResourceContainer, self.reader.children())
return itertools.chain(files, dirs)
def open(self, *args, **kwargs):
raise IsADirectoryError()
class ResourceHandle(Traversable): class ResourceHandle(Traversable):
""" """
Handle to a named resource in a ResourceReader. Handle to a named resource in a ResourceReader.
""" """
def __init__(self, parent, name): def __init__(self, parent: ResourceContainer, name: str):
# type: (ResourceContainer, str) -> None
self.parent = parent self.parent = parent
self.name = name # type: ignore self.name = name # type: ignore
@ -76,30 +95,6 @@ class ResourceHandle(Traversable):
raise RuntimeError("Cannot traverse into a resource") raise RuntimeError("Cannot traverse into a resource")
class ResourceContainer(Traversable):
"""
Traversable container for a package's resources via its reader.
"""
def __init__(self, reader):
# type: (SimpleReader) -> None
self.reader = reader
def is_dir(self):
return True
def is_file(self):
return False
def iterdir(self):
files = (ResourceHandle(self, name) for name in self.reader.resources)
dirs = map(ResourceContainer, self.reader.children())
return itertools.chain(files, dirs)
def open(self, *args, **kwargs):
raise IsADirectoryError()
class TraversableReader(TraversableResources, SimpleReader): class TraversableReader(TraversableResources, SimpleReader):
""" """
A TraversableResources based on SimpleReader. Resource providers A TraversableResources based on SimpleReader. Resource providers

View File

@ -0,0 +1,50 @@
import pathlib
import functools
####
# from jaraco.path 3.4
def build(spec, prefix=pathlib.Path()):
"""
Build a set of files/directories, as described by the spec.
Each key represents a pathname, and the value represents
the content. Content may be a nested directory.
>>> spec = {
... 'README.txt': "A README file",
... "foo": {
... "__init__.py": "",
... "bar": {
... "__init__.py": "",
... },
... "baz.py": "# Some code",
... }
... }
>>> tmpdir = getfixture('tmpdir')
>>> build(spec, tmpdir)
"""
for name, contents in spec.items():
create(contents, pathlib.Path(prefix) / name)
@functools.singledispatch
def create(content, path):
path.mkdir(exist_ok=True)
build(content, prefix=path) # type: ignore
@create.register
def _(content: bytes, path):
path.write_bytes(content)
@create.register
def _(content: str, path):
path.write_text(content)
# end from jaraco.path
####

View File

@ -1,10 +1,24 @@
import typing import typing
import textwrap
import unittest import unittest
import warnings
import importlib
import contextlib
from importlib import resources from importlib import resources
from importlib.resources.abc import Traversable from importlib.resources.abc import Traversable
from . import data01 from . import data01
from . import util from . import util
from . import _path
from test.support import os_helper
from test.support import import_helper
@contextlib.contextmanager
def suppress_known_deprecation():
with warnings.catch_warnings(record=True) as ctx:
warnings.simplefilter('default', category=DeprecationWarning)
yield ctx
class FilesTests: class FilesTests:
@ -25,6 +39,14 @@ class FilesTests:
def test_traversable(self): def test_traversable(self):
assert isinstance(resources.files(self.data), Traversable) assert isinstance(resources.files(self.data), Traversable)
def test_old_parameter(self):
"""
Files used to take a 'package' parameter. Make sure anyone
passing by name is still supported.
"""
with suppress_known_deprecation():
resources.files(package=self.data)
class OpenDiskTests(FilesTests, unittest.TestCase): class OpenDiskTests(FilesTests, unittest.TestCase):
def setUp(self): def setUp(self):
@ -42,5 +64,50 @@ class OpenNamespaceTests(FilesTests, unittest.TestCase):
self.data = namespacedata01 self.data = namespacedata01
class SiteDir:
def setUp(self):
self.fixtures = contextlib.ExitStack()
self.addCleanup(self.fixtures.close)
self.site_dir = self.fixtures.enter_context(os_helper.temp_dir())
self.fixtures.enter_context(import_helper.DirsOnSysPath(self.site_dir))
self.fixtures.enter_context(import_helper.CleanImport())
class ModulesFilesTests(SiteDir, unittest.TestCase):
def test_module_resources(self):
"""
A module can have resources found adjacent to the module.
"""
spec = {
'mod.py': '',
'res.txt': 'resources are the best',
}
_path.build(spec, self.site_dir)
import mod
actual = resources.files(mod).joinpath('res.txt').read_text()
assert actual == spec['res.txt']
class ImplicitContextFilesTests(SiteDir, unittest.TestCase):
def test_implicit_files(self):
"""
Without any parameter, files() will infer the location as the caller.
"""
spec = {
'somepkg': {
'__init__.py': textwrap.dedent(
"""
import importlib.resources as res
val = res.files().joinpath('res.txt').read_text()
"""
),
'res.txt': 'resources are the best',
},
}
_path.build(spec, self.site_dir)
assert importlib.import_module('somepkg').val == 'resources are the best'
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -3,7 +3,7 @@ import importlib
import io import io
import sys import sys
import types import types
from pathlib import Path, PurePath import pathlib
from . import data01 from . import data01
from . import zipdata01 from . import zipdata01
@ -94,7 +94,7 @@ class CommonTests(metaclass=abc.ABCMeta):
def test_pathlib_path(self): def test_pathlib_path(self):
# Passing in a pathlib.PurePath object for the path should succeed. # Passing in a pathlib.PurePath object for the path should succeed.
path = PurePath('utf-8.file') path = pathlib.PurePath('utf-8.file')
self.execute(data01, path) self.execute(data01, path)
def test_importing_module_as_side_effect(self): def test_importing_module_as_side_effect(self):
@ -102,17 +102,6 @@ class CommonTests(metaclass=abc.ABCMeta):
del sys.modules[data01.__name__] del sys.modules[data01.__name__]
self.execute(data01.__name__, 'utf-8.file') self.execute(data01.__name__, 'utf-8.file')
def test_non_package_by_name(self):
# The anchor package cannot be a module.
with self.assertRaises(TypeError):
self.execute(__name__, 'utf-8.file')
def test_non_package_by_package(self):
# The anchor package cannot be a module.
with self.assertRaises(TypeError):
module = sys.modules['test.test_importlib.resources.util']
self.execute(module, 'utf-8.file')
def test_missing_path(self): def test_missing_path(self):
# Attempting to open or read or request the path for a # Attempting to open or read or request the path for a
# non-existent path should succeed if open_resource # non-existent path should succeed if open_resource
@ -144,7 +133,7 @@ class ZipSetupBase:
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
data_path = Path(cls.ZIP_MODULE.__file__) data_path = pathlib.Path(cls.ZIP_MODULE.__file__)
data_dir = data_path.parent data_dir = data_path.parent
cls._zip_path = str(data_dir / 'ziptestdata.zip') cls._zip_path = str(data_dir / 'ziptestdata.zip')
sys.path.append(cls._zip_path) sys.path.append(cls._zip_path)

View File

@ -0,0 +1,6 @@
``importlib.resources.files`` now accepts a module as an anchor instead of
only accepting packages. If a module is passed, resources are resolved
adjacent to that module (in the same package or at the package root). The
parameter was renamed from ``package`` to ``anchor`` with a compatibility
shim for those passing by keyword. Additionally, the new ``anchor``
parameter is now optional and will default to the caller's module.