From 6b987e1baf7ff8d93ca64ab123341b36b3984de5 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 22 Aug 2026 14:42:44 +0200 Subject: [PATCH 1/2] 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 From b439cd6dd879a466d0dcc34d76cbaa9945658cf7 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 11 Sep 2026 15:59:09 +0200 Subject: [PATCH 2/2] coverage --- .coverage | Bin 0 -> 53248 bytes .github/workflows/coverage.yml | 3 ++- .pre-commit-config.yaml | 6 +++--- tests/test_lazy_loader.py | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..4fe97b50ca6744fb3cd7239cd3d8343d4a1c97da GIT binary patch literal 53248 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