Skip to content

Commit d5a3e82

Browse files
authored
Merge pull request #10 from tlambert03/stub-loader
Add `attach_stub` function to load imports from type stubs
2 parents 2cf220b + 132736d commit d5a3e82

File tree

4 files changed

+134
-1
lines changed

4 files changed

+134
-1
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,43 @@ from .edges import (sobel, scharr, prewitt, roberts,
7070

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

73+
### Lazily load subpackages and functions from type stubs
74+
75+
Because static type checkers and IDEs will likely be unable to find your
76+
dynamically declared imports, you can use a [type
77+
stub](https://mypy.readthedocs.io/en/stable/stubs.html) (`.pyi` file) to declare
78+
the imports. However, if used with the above pattern, this results in code
79+
duplication, as you now need to declare your submodules and attributes in two places.
80+
81+
You can infer the `submodules` and `submod_attrs` arguments (explicitly provided
82+
above to `lazy.attach`) from a stub adjacent to the `.py` file by using the
83+
`lazy.attach_stub` function.
84+
85+
Carrying on with the example above:
86+
87+
The `skimage/filters/__init__.py` module would be declared as such:
88+
89+
```python
90+
from ..util import lazy
91+
92+
__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
93+
```
94+
95+
... and the adjacent `skimage/filters/__init__.pyi` stub would contain:
96+
97+
```python
98+
from . import rank
99+
from ._gaussian import gaussian, difference_of_gaussians
100+
from .edges import (sobel, scharr, prewitt, roberts,
101+
laplace, farid)
102+
```
103+
104+
Note that in order for this to work, you must be sure to include the `.pyi`
105+
files in your package distribution. For example, with setuptools, you would need
106+
to [set the `package_data`
107+
option](https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data)
108+
to include `*.pyi` files.
109+
73110
### Early failure
74111

75112
With lazy loading, missing imports no longer fail upon loading the

lazy_loader/__init__.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
55
Makes it easy to load subpackages and functions on demand.
66
"""
7+
import ast
78
import importlib
89
import importlib.util
910
import inspect
1011
import os
1112
import sys
1213
import types
1314

14-
__all__ = ["attach", "load"]
15+
__all__ = ["attach", "load", "attach_stub"]
1516

1617

1718
def attach(package_name, submodules=None, submod_attrs=None):
@@ -189,3 +190,61 @@ def myfunc():
189190
loader.exec_module(module)
190191

191192
return module
193+
194+
195+
class _StubVisitor(ast.NodeVisitor):
196+
"""AST visitor to parse a stub file for submodules and submod_attrs."""
197+
198+
def __init__(self):
199+
self._submodules = set()
200+
self._submod_attrs = {}
201+
202+
def visit_ImportFrom(self, node: ast.ImportFrom):
203+
if node.level != 1:
204+
raise ValueError(
205+
"Only within-module imports are supported (`from .* import`)"
206+
)
207+
if node.module:
208+
attrs: list = self._submod_attrs.setdefault(node.module, [])
209+
attrs.extend(alias.name for alias in node.names)
210+
else:
211+
self._submodules.update(alias.name for alias in node.names)
212+
213+
214+
def attach_stub(package_name: str, filename: str):
215+
"""Attach lazily loaded submodules, functions from a type stub.
216+
217+
This is a variant on ``attach`` that will parse a `.pyi` stub file to
218+
infer ``submodules`` and ``submod_attrs``. This allows static type checkers
219+
to find imports, while still providing lazy loading at runtime.
220+
221+
Parameters
222+
----------
223+
package_name : str
224+
Typically use ``__name__``.
225+
filename : str
226+
Path to `.py` file which has an adjacent `.pyi` file.
227+
Typically use ``__file__``.
228+
229+
Returns
230+
-------
231+
__getattr__, __dir__, __all__
232+
The same output as ``attach``.
233+
234+
Raises
235+
------
236+
ValueError
237+
If a stub file is not found for `filename`, or if the stubfile is formmated
238+
incorrectly (e.g. if it contains an relative import from outside of the module)
239+
"""
240+
stubfile = filename if filename.endswith("i") else f"{filename}i"
241+
242+
if not os.path.exists(stubfile):
243+
raise ValueError(f"Cannot load imports from non-existent stub {stubfile!r}")
244+
245+
with open(stubfile) as f:
246+
stub_node = ast.parse(f.read())
247+
248+
visitor = _StubVisitor()
249+
visitor.visit(stub_node)
250+
return attach(package_name, visitor._submodules, visitor._submod_attrs)

tests/fake_pkg/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .some_func import some_func

tests/test_lazy_loader.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,39 @@ def test_attach_same_module_and_attr_name():
108108
from fake_pkg.some_func import some_func
109109

110110
assert isinstance(some_func, types.FunctionType)
111+
112+
113+
FAKE_STUB = """
114+
from . import rank
115+
from ._gaussian import gaussian
116+
from .edges import sobel, scharr, prewitt, roberts
117+
"""
118+
119+
120+
def test_stub_loading(tmp_path):
121+
stub = tmp_path / "stub.pyi"
122+
stub.write_text(FAKE_STUB)
123+
_get, _dir, _all = lazy.attach_stub("my_module", str(stub))
124+
expect = {"gaussian", "sobel", "scharr", "prewitt", "roberts", "rank"}
125+
assert set(_dir()) == set(_all) == expect
126+
127+
128+
def test_stub_loading_parity():
129+
import fake_pkg
130+
131+
from_stub = lazy.attach_stub(fake_pkg.__name__, fake_pkg.__file__)
132+
stub_getter, stub_dir, stub_all = from_stub
133+
assert stub_all == fake_pkg.__all__
134+
assert stub_dir() == fake_pkg.__lazy_dir__()
135+
assert stub_getter("some_func") == fake_pkg.some_func
136+
137+
138+
def test_stub_loading_errors(tmp_path):
139+
stub = tmp_path / "stub.pyi"
140+
stub.write_text("from ..mod import func\n")
141+
142+
with pytest.raises(ValueError, match="Only within-module imports are supported"):
143+
lazy.attach_stub("name", str(stub))
144+
145+
with pytest.raises(ValueError, match="Cannot load imports from non-existent stub"):
146+
lazy.attach_stub("name", "not a file")

0 commit comments

Comments
 (0)