diff --git a/importlib_resources/__init__.py b/importlib_resources/__init__.py index 27d6c7f..2b0f4e8 100644 --- a/importlib_resources/__init__.py +++ b/importlib_resources/__init__.py @@ -8,8 +8,6 @@ """ from ._common import ( - Anchor, - Package, as_file, files, ) @@ -38,3 +36,28 @@ 'read_binary', 'read_text', ] + +TYPE_CHECKING = False + +# Type checkers needs this block to understand what __getattr__() exports currently. +if TYPE_CHECKING: + from ._typing import Anchor, Package + + +def __getattr__(name: str) -> object: + # Defer import to avoid an import-time dependency on typing, since Package and + # Anchor are type aliases that use symbols from typing. + if name in {"Anchor", "Package"}: + from . import _typing + + obj = getattr(_typing, name) + + else: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + globals()[name] = obj + return obj + +def __dir__() -> list[str]: + return sorted(globals().keys() | {"Anchor", "Package"}) diff --git a/importlib_resources/_common.py b/importlib_resources/_common.py index 5f41c26..19741f5 100644 --- a/importlib_resources/_common.py +++ b/importlib_resources/_common.py @@ -8,12 +8,9 @@ import tempfile import types import warnings -from typing import Optional, Union, cast -from .abc import ResourceReader, Traversable - -Package = Union[types.ModuleType, str] -Anchor = Package +from . import _typing as _t +from . import abc def package_to_anchor(func): @@ -49,14 +46,14 @@ def wrapper(anchor=undefined, package=undefined): @package_to_anchor -def files(anchor: Optional[Anchor] = None) -> Traversable: +def files(anchor: "_t.Optional[_t.Anchor]" = None) -> "abc.Traversable": """ Get a Traversable resource for an anchor. """ return from_package(resolve(anchor)) -def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]: +def get_resource_reader(package: types.ModuleType) -> "_t.Optional[abc.ResourceReader]": """ Return the package's loader if it's a ResourceReader. """ @@ -72,19 +69,14 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]: return reader(spec.name) # type: ignore[union-attr] -@functools.singledispatch -def resolve(cand: Optional[Anchor]) -> types.ModuleType: - return cast(types.ModuleType, cand) - - -@resolve.register -def _(cand: str) -> types.ModuleType: - return importlib.import_module(cand) +def resolve(cand: "_t.Optional[_t.Anchor]") -> types.ModuleType: + if cand is None: + cand = _infer_caller().f_globals['__name__'] - -@resolve.register -def _(cand: None) -> types.ModuleType: - return resolve(_infer_caller().f_globals['__name__']) + if isinstance(cand, str): + return importlib.import_module(cand) + else: + return cand # type: ignore[return-value] # Guarded by usage in from_package. def _infer_caller(): @@ -149,7 +141,7 @@ def _temp_file(path): return _tempfile(path.read_bytes, suffix=path.name) -def _is_present_dir(path: Traversable) -> bool: +def _is_present_dir(path: "abc.Traversable") -> bool: """ Some Traversables implement ``is_dir()`` to raise an exception (i.e. ``FileNotFoundError``) when the @@ -162,18 +154,18 @@ def _is_present_dir(path: Traversable) -> bool: return False -@functools.singledispatch def as_file(path): """ Given a Traversable object, return that object as a path on the local file system in a context manager. """ + if isinstance(path, pathlib.Path): + return _as_file_Path(path) return _temp_dir(path) if _is_present_dir(path) else _temp_file(path) -@as_file.register(pathlib.Path) @contextlib.contextmanager -def _(path): +def _as_file_Path(path): """ Degenerate behavior for pathlib.Path objects. """ diff --git a/importlib_resources/_traversable.py b/importlib_resources/_traversable.py new file mode 100644 index 0000000..d94f71b --- /dev/null +++ b/importlib_resources/_traversable.py @@ -0,0 +1,121 @@ +import abc +import itertools +import os +import pathlib +from collections.abc import Iterator +from typing import ( + Any, + BinaryIO, + Literal, + Optional, + Protocol, + TextIO, + Union, + overload, + runtime_checkable, +) + +from .abc import TraversalError + +StrPath = Union[str, os.PathLike[str]] + + +@runtime_checkable +class Traversable(Protocol): + """ + An object with a subset of pathlib.Path methods suitable for + traversing directories and opening files. + + Any exceptions that occur when accessing the backing resource + may propagate unaltered. + """ + + @abc.abstractmethod + def iterdir(self) -> Iterator["Traversable"]: + """ + Yield Traversable objects in self + """ + + def read_bytes(self) -> bytes: + """ + Read contents of self as bytes + """ + with self.open('rb') as strm: + return strm.read() + + def read_text( + self, encoding: Optional[str] = None, errors: Optional[str] = None + ) -> str: + """ + Read contents of self as text + """ + with self.open(encoding=encoding, errors=errors) as strm: + return strm.read() + + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Return True if self is a directory + """ + + @abc.abstractmethod + def is_file(self) -> bool: + """ + Return True if self is a file + """ + + def joinpath(self, *descendants: StrPath) -> "Traversable": + """ + Return Traversable resolved with any descendants applied. + + Each descendant should be a path segment relative to self + and each may contain multiple levels separated by + ``posixpath.sep`` (``/``). + """ + if not descendants: + return self + names = itertools.chain.from_iterable( + path.parts for path in map(pathlib.PurePosixPath, descendants) + ) + target = next(names) + matches = ( + traversable for traversable in self.iterdir() if traversable.name == target + ) + try: + match = next(matches) + except StopIteration: + raise TraversalError( + "Target not found during traversal.", target, list(names) + ) + return match.joinpath(*names) + + def __truediv__(self, child: StrPath) -> "Traversable": + """ + Return Traversable child in self + """ + return self.joinpath(child) + + @overload + def open(self, mode: Literal['r'] = 'r', *args: Any, **kwargs: Any) -> TextIO: ... + + @overload + def open(self, mode: Literal['rb'], *args: Any, **kwargs: Any) -> BinaryIO: ... + + @abc.abstractmethod + def open( + self, mode: str = 'r', *args: Any, **kwargs: Any + ) -> Union[TextIO, BinaryIO]: + """ + mode may be 'r' or 'rb' to open as text or binary. Return a handle + suitable for reading (same as pathlib.Path.open). + + When opening as text, accepts encoding parameters such as those + accepted by io.TextIOWrapper. + """ + + @property + @abc.abstractmethod + def name(self) -> str: + """ + The base name of this object without any parent references. + """ diff --git a/importlib_resources/_typing.py b/importlib_resources/_typing.py new file mode 100644 index 0000000..c909d00 --- /dev/null +++ b/importlib_resources/_typing.py @@ -0,0 +1,103 @@ +"""Internal. + +A lazy re-export shim/middleman for typing-related symbols and annotation-related symbols to +avoid import-time dependencies on expensive modules (like `typing`). Some symbols may +eventually be needed at runtime, but their import/creation will be "on demand" to +improve startup performance. + +Usage Notes +----------- +Do not directly import annotation-related symbols from this module +(e.g. ``from ._lazy import Any``)! Doing so will trigger the module-level `__getattr__`, +causing the modules of shimmed symbols, e.g. `typing`, to get imported. Instead, import +the module and use symbols via attribute access as needed +(e.g. ``from . import _lazy [as _t]``). + +Additionally, to avoid those symbols being evaluated at runtime, which would *also* +cause shimmed modules to get imported, make sure to defer evaluation of annotations via +the following: + + a) <3.14: Manual stringification of annotations, or + `from __future__ import annotations`. + b) >=3.14: Nothing, thanks to default PEP 649 semantics. +""" + +__all__ = ( + # ---- Typing/annotation symbols ---- + # collections.abc + "Iterable", + "Iterator", + + # typing + "Any", + "BinaryIO", + "NoReturn", + "Optional", + "Text", + "Union", + + # Other + "Package", + "Anchor", + "StrPath", + +) # fmt: skip + + +TYPE_CHECKING = False + + +# Type checkers needs this block to understand what __getattr__() exports currently. +if TYPE_CHECKING: + import os + import types + from collections.abc import Iterable, Iterator + from typing import ( + Any, + BinaryIO, + NoReturn, + Optional, + Text, + Union, + ) + + from typing_extensions import TypeAlias + + Package: TypeAlias = Union[types.ModuleType, str] + Anchor = Package + StrPath: TypeAlias = Union[str, os.PathLike[str]] + + +def __getattr__(name: str) -> object: + if name in {"Iterable", "Iterator"}: + import collections.abc + + obj = getattr(collections.abc, name) + + elif name in {"Any", "BinaryIO", "NoReturn", "Text", "Optional", "Union"}: + import typing + + obj = getattr(typing, name) + + elif name in {"Package", "Anchor"}: + import types + from typing import Union + + obj = Union[types.ModuleType, str] + + elif name == "StrPath": + import os + from typing import Union + + obj = Union[str, os.PathLike[str]] + + else: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + globals()[name] = obj + return obj + + +def __dir__() -> list[str]: + return sorted(globals().keys() | __all__) diff --git a/importlib_resources/abc.py b/importlib_resources/abc.py index 883d332..8d96db6 100644 --- a/importlib_resources/abc.py +++ b/importlib_resources/abc.py @@ -1,33 +1,49 @@ +from __future__ import annotations + import abc -import itertools -import os -import pathlib -from typing import ( - Any, - BinaryIO, - Iterable, - Iterator, - NoReturn, - Literal, - Optional, - Protocol, - Text, - TextIO, - Union, - overload, - runtime_checkable, -) - -StrPath = Union[str, os.PathLike[str]] + +from . import _typing as _t +from ._typing import TYPE_CHECKING __all__ = ["ResourceReader", "Traversable", "TraversableResources"] +# A hack for the following targets: +# a) Type checkers, so they can understand what __getattr__() exports. +# b) Internal annotations, so that Traversable can be used in deferred annotations via +# _self_mod.Traversable. +if TYPE_CHECKING: + from ._traversable import Traversable + + class _self_mod: + from ._traversable import Traversable + +else: + _self_mod = __import__("sys").modules[__name__] + + +def __getattr__(name: str) -> object: + # Defer import to avoid an import-time dependency on typing, since Traversable + # subclasses typing.Protocol. + if name == "Traversable": + from ._traversable import Traversable as obj + else: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + globals()[name] = obj + return obj + + +def __dir__() -> list[str]: + return sorted(globals().keys() | {"Traversable"}) + + class ResourceReader(metaclass=abc.ABCMeta): """Abstract base class for loaders to provide resource reading support.""" @abc.abstractmethod - def open_resource(self, resource: Text) -> BinaryIO: + def open_resource(self, resource: _t.Text) -> _t.BinaryIO: """Return an opened, file-like object for binary reading. The 'resource' argument is expected to represent only a file name. @@ -39,7 +55,7 @@ def open_resource(self, resource: Text) -> BinaryIO: raise FileNotFoundError @abc.abstractmethod - def resource_path(self, resource: Text) -> Text: + def resource_path(self, resource: _t.Text) -> _t.Text: """Return the file system path to the specified resource. The 'resource' argument is expected to represent only a file name. @@ -52,7 +68,7 @@ def resource_path(self, resource: Text) -> Text: raise FileNotFoundError @abc.abstractmethod - def is_resource(self, path: Text) -> bool: + def is_resource(self, path: _t.Text) -> bool: """Return True if the named 'path' is a resource. Files are resources, directories are not. @@ -60,7 +76,7 @@ def is_resource(self, path: Text) -> bool: raise FileNotFoundError @abc.abstractmethod - def contents(self) -> Iterable[str]: + def contents(self) -> _t.Iterable[str]: """Return an iterable of entries in `package`.""" raise FileNotFoundError @@ -69,107 +85,6 @@ class TraversalError(Exception): pass -@runtime_checkable -class Traversable(Protocol): - """ - An object with a subset of pathlib.Path methods suitable for - traversing directories and opening files. - - Any exceptions that occur when accessing the backing resource - may propagate unaltered. - """ - - @abc.abstractmethod - def iterdir(self) -> Iterator["Traversable"]: - """ - Yield Traversable objects in self - """ - - def read_bytes(self) -> bytes: - """ - Read contents of self as bytes - """ - with self.open('rb') as strm: - return strm.read() - - def read_text( - self, encoding: Optional[str] = None, errors: Optional[str] = None - ) -> str: - """ - Read contents of self as text - """ - with self.open(encoding=encoding, errors=errors) as strm: - return strm.read() - - @abc.abstractmethod - def is_dir(self) -> bool: - """ - Return True if self is a directory - """ - - @abc.abstractmethod - def is_file(self) -> bool: - """ - Return True if self is a file - """ - - def joinpath(self, *descendants: StrPath) -> "Traversable": - """ - Return Traversable resolved with any descendants applied. - - Each descendant should be a path segment relative to self - and each may contain multiple levels separated by - ``posixpath.sep`` (``/``). - """ - if not descendants: - return self - names = itertools.chain.from_iterable( - path.parts for path in map(pathlib.PurePosixPath, descendants) - ) - target = next(names) - matches = ( - traversable for traversable in self.iterdir() if traversable.name == target - ) - try: - match = next(matches) - except StopIteration: - raise TraversalError( - "Target not found during traversal.", target, list(names) - ) - return match.joinpath(*names) - - def __truediv__(self, child: StrPath) -> "Traversable": - """ - Return Traversable child in self - """ - return self.joinpath(child) - - @overload - def open(self, mode: Literal['r'] = 'r', *args: Any, **kwargs: Any) -> TextIO: ... - - @overload - def open(self, mode: Literal['rb'], *args: Any, **kwargs: Any) -> BinaryIO: ... - - @abc.abstractmethod - def open( - self, mode: str = 'r', *args: Any, **kwargs: Any - ) -> Union[TextIO, BinaryIO]: - """ - mode may be 'r' or 'rb' to open as text or binary. Return a handle - suitable for reading (same as pathlib.Path.open). - - When opening as text, accepts encoding parameters such as those - accepted by io.TextIOWrapper. - """ - - @property - @abc.abstractmethod - def name(self) -> str: - """ - The base name of this object without any parent references. - """ - - class TraversableResources(ResourceReader): """ The required interface for providing traversable @@ -177,17 +92,17 @@ class TraversableResources(ResourceReader): """ @abc.abstractmethod - def files(self) -> "Traversable": + def files(self) -> _self_mod.Traversable: """Return a Traversable object for the loaded package.""" - def open_resource(self, resource: StrPath) -> BinaryIO: + def open_resource(self, resource: _t.StrPath) -> _t.BinaryIO: return self.files().joinpath(resource).open('rb') - def resource_path(self, resource: Any) -> NoReturn: + def resource_path(self, resource: _t.Any) -> _t.NoReturn: raise FileNotFoundError(resource) - def is_resource(self, path: StrPath) -> bool: + def is_resource(self, path: _t.StrPath) -> bool: return self.files().joinpath(path).is_file() - def contents(self) -> Iterator[str]: + def contents(self) -> _t.Iterator[str]: return (item.name for item in self.files().iterdir())