From 6b987e1baf7ff8d93ca64ab123341b36b3984de5 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 14:42:44 +0200 Subject: [PATCH] native lazy imports with PEP810 --- .github/workflows/test.yml | 3 +- README.md | 17 +++++++++ src/lazy_loader/__init__.py | 66 ++++++++++++++++++++++++++++++++++ tests/test_lazy_loader.py | 70 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b805006..f0da650 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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", ] diff --git a/README.md b/README.md index ab35b98..9c31c56 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lazy_loader/__init__.py b/src/lazy_loader/__init__.py index 23b5382..6ec646e 100644 --- a/src/lazy_loader/__init__.py +++ b/src/lazy_loader/__init__.py @@ -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"", "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. @@ -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 @@ -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() diff --git a/tests/test_lazy_loader.py b/tests/test_lazy_loader.py index d68537f..9507854 100644 --- a/tests/test_lazy_loader.py +++ b/tests/test_lazy_loader.py @@ -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