Skip to content

Avoid import-time dependency on typing in common cases #329

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions importlib_resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
"""

from ._common import (
Anchor,
Package,
as_file,
files,
)
Expand Down Expand Up @@ -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"})
38 changes: 15 additions & 23 deletions importlib_resources/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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.
"""
Expand Down
121 changes: 121 additions & 0 deletions importlib_resources/_traversable.py
Original file line number Diff line number Diff line change
@@ -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.
"""
103 changes: 103 additions & 0 deletions importlib_resources/_typing.py
Original file line number Diff line number Diff line change
@@ -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__)
Loading