Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ jobs:
"3.11",
"3.12",
"3.13",
"3.14-dev",
"3.14",
"3.15-dev",
"pypy-3.9",
"pypy-3.10",
]
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ from .edges import (sobel, scharr, prewitt, roberts,

Except that all subpackages (such as `rank`) and functions (such as `sobel`) are loaded upon access.

### Native lazy imports on Python 3.15+

Python 3.15 introduced native lazy imports
([PEP 810](https://peps.python.org/pep-0810/)). On 3.15 and newer,
`lazy.attach` (and `lazy.attach_stub`) automatically delegates to this
mechanism: attached names are bound in the package namespace as native
lazy proxies, which the interpreter resolves—thread-safely—on first
access. No code changes are needed, and the behavior is the same, with
one visible difference: attached names appear in the package's
`__dict__` (as proxies) before first access, instead of materializing
on first access.

Note that `lazy.load` continues to use its own proxy mechanism on all
Python versions, since PEP 810 proxies only resolve when accessed
through a module namespace. In code that only runs on Python 3.15+, you
can use a plain `lazy import numpy` statement instead of `lazy.load`.

### Type checkers

Static type checkers and IDEs cannot infer type information from
Expand Down
66 changes: 66 additions & 0 deletions src/lazy_loader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,61 @@

threadlock = threading.Lock()

# PEP 810 explicit lazy imports, available from Python 3.15
_NATIVE_LAZY_IMPORTS = sys.version_info >= (3, 15)


def _attach_native(package_name, submodules, submod_attrs):
"""Bind native lazy import proxies (PEP 810) in the package namespace.

Names already bound in the package namespace are left untouched.
Returns False if the caller should fall back to the classic
``__getattr__``-based mechanism.
"""
package = sys.modules.get(package_name)
if package is None:
# Not inside the package's import; cannot bind proxies in its
# namespace.
return False

# Since the names are embedded in generated import statements below,
# ensure they are identifiers and not arbitrary code.
names = [package_name, *submodules, *submod_attrs]
names.extend(attr for attrs in submod_attrs.values() for attr in attrs)
if not all(part.isidentifier() for name in names for part in name.split(".")):
return False

pkg_dict = vars(package)

# Absolute imports, like the classic __getattr__ mechanism uses, so that
# no relative-import resolution (via __spec__ or __package__) is needed.
lines = [
f"lazy from {package_name} import {name}"
for name in sorted(submodules)
if name not in pkg_dict
]
for mod, attrs in submod_attrs.items():
new_attrs = [a for a in attrs if a not in pkg_dict and a not in submodules]
if new_attrs:
lines.append(
f"lazy from {package_name}.{mod} import {', '.join(new_attrs)}"
)

if not lines:
return True

try:
code = compile(
"\n".join(lines), f"<lazy_loader.attach {package_name!r}>", "exec"
)
except SyntaxError:
# A submodule or attribute name that is not expressible as import
# syntax (e.g., a reserved keyword).
return False

exec(code, pkg_dict)
return True


def attach(package_name, submodules=None, submod_attrs=None):
"""Attach lazily loaded submodules, functions, or other attributes.
Expand All @@ -41,6 +96,9 @@ def attach(package_name, submodules=None, submod_attrs=None):
__name__, ["mysubmodule", "anothersubmodule"], {"foo": ["someattr"]}
)

On Python 3.15 and newer, this delegates to the interpreter's native
lazy import mechanism (PEP 810) whenever possible.

Parameters
----------
package_name : str
Expand Down Expand Up @@ -96,6 +154,14 @@ def __dir__():
if eager_import:
for attr in set(attr_to_modules.keys()) | submodules:
__getattr__(attr)
elif _NATIVE_LAZY_IMPORTS:
# On Python 3.15+, delegate to native lazy imports (PEP 810) where
# possible. The proxies are bound directly in the package namespace,
# so the returned __getattr__ is then only consulted for unknown
# names. If native binding is not possible (e.g. `package_name` is
# not an imported module), the classic __getattr__ mechanism above
# provides the lazy behavior as before.
_attach_native(package_name, submodules, submod_attrs)

return __getattr__, __dir__, __all__.copy()

Expand Down
70 changes: 70 additions & 0 deletions tests/test_lazy_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,76 @@ def test_attach_same_module_and_attr_name(clean_fake_pkg, eager_import):
assert isinstance(some_func, types.FunctionType)


NATIVE_LAZY_IMPORTS = sys.version_info >= (3, 15)


def test_attach_native_proxies(clean_fake_pkg):
from tests import fake_pkg

if NATIVE_LAZY_IMPORTS:
# Names are bound in the package namespace as native lazy proxies
assert "some_func" in vars(fake_pkg)
assert type(vars(fake_pkg)["some_func"]).__name__ == "lazy_import"
else:
# The classic mechanism leaves names unbound until first access
assert "some_func" not in vars(fake_pkg)

# Either way, nothing is imported until first attribute access
assert "tests.fake_pkg.some_func" not in sys.modules
assert isinstance(fake_pkg.some_func, types.FunctionType)
assert "tests.fake_pkg.some_func" in sys.modules


def test_attach_native_keeps_existing_bindings():
# A name already bound in the package namespace shadows the lazily
# attached one, on all Python versions.
name = "lazy_loader_test_existing_pkg"
mod = types.ModuleType(name)
mod.some_attr = "sentinel"
sys.modules[name] = mod
try:
getattr_, _, all_ = lazy.attach(
name, submod_attrs={"sub": ["some_attr", "other_attr"]}
)
assert mod.some_attr == "sentinel"
assert all_ == ["other_attr", "some_attr"]
if NATIVE_LAZY_IMPORTS:
assert type(vars(mod)["other_attr"]).__name__ == "lazy_import"
# Unknown names raise AttributeError through the returned __getattr__
with pytest.raises(AttributeError):
getattr_("unknown_attr")
finally:
del sys.modules[name]


def test_attach_rejects_non_identifier_names():
# Names that are not identifiers must never reach the generated import
# statements of the native (PEP 810) path; the classic __getattr__
# mechanism handles them as plain strings.
name = "lazy_loader_test_nonidentifier_pkg"
mod = types.ModuleType(name)
sys.modules[name] = mod
try:
evil = "nosuchmod import x\ninjected = 1\nlazy from victim.nosuchmod"
getattr_, _, _ = lazy.attach(name, submod_attrs={evil: ["x"]})
assert "injected" not in vars(mod)
assert "x" not in vars(mod)
with pytest.raises(ImportError):
getattr_("x")
finally:
del sys.modules[name]


def test_attach_falls_back_without_module():
# attach() with a package name that is not in sys.modules cannot bind
# native proxies and must keep the classic __getattr__ mechanism.
getattr_, _, _ = lazy.attach(
"lazy_loader_test_not_a_module", submod_attrs={"sub": ["some_attr"]}
)
with pytest.raises(ImportError):
getattr_("some_attr")


FAKE_STUB = """
from . import rank
from ._gaussian import gaussian
Expand Down