Skip to content

Commit d3d07c0

Browse files
Daverballhenri-hulski
authored andcommitted
Add type hints
1 parent 7e832d5 commit d3d07c0

7 files changed

Lines changed: 181 additions & 58 deletions

File tree

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ jobs:
2424
# Steps represent a sequence of tasks that will be executed as part of the job
2525
steps:
2626
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
27-
- uses: "actions/checkout@v2"
28-
- uses: "actions/setup-python@v2"
27+
- uses: "actions/checkout@v6"
28+
- uses: "actions/setup-python@v6"
2929
with:
3030
python-version: "${{ matrix.python-version }}"
3131
- name: "Install dependencies"

importscan/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
from .scan import scan # noqa
1+
from .scan import scan
2+
3+
__all__ = ("scan",)

importscan/scan.py

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1-
from pkgutil import iter_modules
1+
from __future__ import annotations
2+
23
import sys
4+
from pkgutil import iter_modules
5+
from typing import TYPE_CHECKING
36

7+
if TYPE_CHECKING:
8+
from collections.abc import Callable, Generator, Iterable
9+
from importlib.abc import Loader
10+
from types import ModuleType
11+
from typing_extensions import TypeIs
12+
from importscan.types import IgnoreModule, ModuleInfo, StrOrBytesPath
413

5-
def scan(package, ignore=None, handle_error=None):
14+
15+
def scan(
16+
package: ModuleType,
17+
ignore: Iterable[IgnoreModule] | IgnoreModule | None = None,
18+
handle_error: Callable[[str, Exception], object] | None = None,
19+
) -> None:
620
"""Scan a package by importing it.
721
822
A framework can provide registration decorators: a decorator that
@@ -99,22 +113,39 @@ def handle_error(name, e):
99113
is_ignored=is_ignored,
100114
handle_error=handle_error,
101115
):
102-
try:
103-
loader = importer.find_spec(modname).loader
104-
except AttributeError:
105-
# zipimport.zipimporter doesn't have find_spec
106-
loader = importer.find_module(modname)
116+
# FIXME: Add support for MetaPathFinder? But how would that work?
117+
# What path do we pass in to get the correct result?
118+
# We probably would need to remember the value of path we passed
119+
# into iter_modules for submodules/subpackages. There's also
120+
# the additional issue that not all finders will implement the
121+
# non-standard iter_modules method, but without it there's
122+
# no way to list all of the modules. Also since walk_packages
123+
# already imports all of the packages, why are we importing
124+
# them again here? Shouldn't we only import modules here?
125+
# Also why do we do only use `import_module` here, but not
126+
# in `walk_packages`? Doesn't that mean that the additional
127+
# check in `import_module` doesn't do anything for packages?
128+
loader = importer.find_spec(modname).loader # type: ignore
129+
assert loader is not None
130+
107131
try:
108132
import_module(modname, loader, handle_error)
109133
finally:
110-
if hasattr(loader, "file") and hasattr(loader.file, "close"):
111-
loader.file.close()
112-
113-
114-
def import_module(modname, loader, handle_error):
134+
if hasattr(loader, "file") and hasattr(
135+
loader.file, # pyright: ignore[reportAttributeAccessIssue]
136+
"close",
137+
):
138+
loader.file.close() # pyright: ignore[reportAttributeAccessIssue]
139+
140+
141+
def import_module(
142+
modname: str,
143+
loader: Loader,
144+
handle_error: Callable[[str, Exception], object] | None,
145+
) -> None:
115146
get_filename = getattr(loader, "get_filename", None)
116147
if get_filename is None:
117-
get_filename = loader._get_filename
148+
get_filename = loader._get_filename # type: ignore[attr-defined]
118149
try:
119150
fn = get_filename(modname)
120151
except TypeError:
@@ -135,10 +166,13 @@ def import_module(modname, loader, handle_error):
135166
raise
136167

137168

138-
def get_is_ignored(package, ignore):
169+
def get_is_ignored(
170+
package: ModuleType, ignore: Iterable[IgnoreModule] | IgnoreModule | None
171+
) -> Callable[[str], bool]:
172+
139173
pkg_name = package.__name__
140174

141-
def is_nonstr_iter(v):
175+
def is_nonstr_iter(v: object) -> TypeIs[Iterable[IgnoreModule]]:
142176
if isinstance(v, str): # pragma: no cover
143177
return False
144178
return hasattr(v, "__iter__")
@@ -157,23 +191,28 @@ def is_nonstr_iter(v):
157191
# functions, e.g. re.compile('pattern').search
158192
callable_ignores = [ign for ign in ignore if callable(ign)]
159193

160-
def is_ignored(fullname):
194+
def is_ignored(fullname: str) -> bool:
161195
for ign in rel_ignores:
162196
if fullname.startswith(pkg_name + ign):
163197
return True
164198
for ign in abs_ignores:
165199
# non-leading-dotted name absolute object name
166200
if fullname.startswith(ign):
167201
return True
168-
for ign in callable_ignores:
169-
if ign(fullname):
202+
for ign_fn in callable_ignores:
203+
if ign_fn(fullname):
170204
return True
171205
return False
172206

173207
return is_ignored
174208

175209

176-
def walk_packages(path=None, prefix="", is_ignored=None, handle_error=None):
210+
def walk_packages(
211+
path: Iterable[StrOrBytesPath] | None = None,
212+
prefix: str = "",
213+
is_ignored: Callable[[str], bool] | None = None,
214+
handle_error: Callable[[str, Exception], object] | None = None,
215+
) -> Generator[ModuleInfo]:
177216
"""Yields (module_finder, name, ispkg) for all modules recursively
178217
on path, or, if path is ``None``, all accessible modules.
179218
@@ -205,10 +244,11 @@ def walk_packages(path=None, prefix="", is_ignored=None, handle_error=None):
205244
206245
"""
207246

208-
def seen(p, m={}):
247+
def seen(p: str, m: set[str] = set()) -> bool:
209248
if p in m: # pragma: no cover
210249
return True
211-
m[p] = True
250+
m.add(p)
251+
return False
212252

213253
# iter_modules is nonrecursive
214254
for module_finder, name, ispkg in iter_modules(path, prefix):
@@ -235,6 +275,6 @@ def seen(p, m={}):
235275
path = getattr(sys.modules[name], "__path__", None) or []
236276

237277
# don't traverse path items we've seen before
238-
path = [p for p in path if not seen(p)]
278+
path = [p for p in path if not seen(p)] # pyright: ignore
239279

240280
yield from walk_packages(path, name + ".", is_ignored, handle_error)
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
from __future__ import annotations
2+
13
calls = 0
24

35

4-
def call():
6+
def call() -> None:
57
global calls
68
calls += 1
79

810

9-
def reset():
11+
def reset() -> None:
1012
global calls
1113
calls = 0

importscan/tests/test_importscan.py

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,93 @@
1-
import sys
2-
import re
3-
import os
1+
from __future__ import annotations
2+
43
import contextlib
4+
import os
5+
import re
6+
import sys
7+
from typing import TYPE_CHECKING
8+
9+
import pytest
10+
from pytest import raises
511
from importscan import scan
612
from . import fixtures
7-
from pytest import raises
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Generator
816

917
# note that due to the nature of imports, we need to have a unique fixture
1018
# for each test
1119

1220

1321
@contextlib.contextmanager
14-
def with_entry_in_sys_path(entry):
22+
def with_entry_in_sys_path(entry: str) -> Generator[None]:
1523
"""Context manager that temporarily puts an entry at head of sys.path"""
1624
sys.path.insert(0, entry)
1725
yield
1826
sys.path.remove(entry)
1927

2028

21-
def zip_file_in_sys_path():
29+
def zip_file_in_sys_path() -> contextlib.AbstractContextManager[None]:
2230
"""Context manager that puts zipped.zip at head of sys.path"""
2331
zip_pkg_path = os.path.join(
2432
os.path.dirname(__file__), "fixtures", "zipped.zip"
2533
)
2634
return with_entry_in_sys_path(zip_pkg_path)
2735

2836

29-
def setup_function(function):
37+
@pytest.fixture(autouse=True, scope="function")
38+
def reset_fixtures() -> None:
3039
fixtures.reset()
3140

3241

33-
def test_empty_package():
42+
def test_empty_package() -> None:
3443
from .fixtures import empty_package
3544

3645
scan(empty_package)
3746

3847
assert fixtures.calls == 1
3948

4049

41-
def test_module():
50+
def test_module() -> None:
4251
from .fixtures import module
4352

4453
scan(module)
4554

4655
assert fixtures.calls == 1
4756

4857

49-
def test_package():
58+
def test_package() -> None:
5059
from .fixtures import package
5160

5261
scan(package)
5362

5463
assert fixtures.calls == 1
5564

5665

57-
def test_empty_subpackage():
66+
def test_empty_subpackage() -> None:
5867
from .fixtures import empty_subpackage
5968

6069
scan(empty_subpackage)
6170

6271
assert fixtures.calls == 1
6372

6473

65-
def test_subpackage():
74+
def test_subpackage() -> None:
6675
from .fixtures import subpackage
6776

6877
scan(subpackage)
6978

7079
assert fixtures.calls == 1
7180

7281

73-
def test_ignore_module_relative():
82+
def test_ignore_module_relative() -> None:
7483
from .fixtures import ignore_module
7584

7685
scan(ignore_module, ignore=[".module"])
7786

7887
assert fixtures.calls == 0
7988

8089

81-
def test_ignore_module_absolute():
90+
def test_ignore_module_absolute() -> None:
8291
from .fixtures import ignore_module_absolute
8392

8493
scan(
@@ -89,57 +98,57 @@ def test_ignore_module_absolute():
8998
assert fixtures.calls == 0
9099

91100

92-
def test_ignore_module_function():
101+
def test_ignore_module_function() -> None:
93102
from .fixtures import ignore_module_function
94103

95104
scan(ignore_module_function, ignore=re.compile("module$").search)
96105

97106
assert fixtures.calls == 0
98107

99108

100-
def test_ignore_subpackage_relative():
109+
def test_ignore_subpackage_relative() -> None:
101110
from .fixtures import ignore_subpackage
102111

103112
scan(ignore_subpackage, ignore=[".sub"])
104113

105114
assert fixtures.calls == 0
106115

107116

108-
def test_ignore_subpackage_function():
117+
def test_ignore_subpackage_function() -> None:
109118
from .fixtures import ignore_subpackage_function
110119

111120
scan(ignore_subpackage_function, ignore=re.compile("sub$").search)
112121

113122
assert fixtures.calls == 0
114123

115124

116-
def test_ignore_subpackage_module_relative():
125+
def test_ignore_subpackage_module_relative() -> None:
117126
from .fixtures import ignore_subpackage_module
118127

119128
scan(ignore_subpackage_module, ignore=[".sub.module"])
120129

121130
assert fixtures.calls == 0
122131

123132

124-
def test_importerror():
133+
def test_importerror() -> None:
125134
from .fixtures import importerror
126135

127136
with raises(ImportError):
128137
scan(importerror)
129138

130139

131-
def test_attributeerror():
140+
def test_attributeerror() -> None:
132141
from .fixtures import attributeerror
133142

134143
with raises(AttributeError):
135144
scan(attributeerror)
136145

137146

138-
def test_importerror_handle_error():
147+
def test_importerror_handle_error() -> None:
139148
from .fixtures import importerror_handle_error
140149

141150
# skip import errors
142-
def handle_error(name, e):
151+
def handle_error(name: str, e: Exception) -> None:
143152
if not isinstance(e, ImportError):
144153
raise e
145154

@@ -148,30 +157,30 @@ def handle_error(name, e):
148157
assert fixtures.calls == 1
149158

150159

151-
def test_attributeerror_not_handle_error():
160+
def test_attributeerror_not_handle_error() -> None:
152161
from .fixtures import attributeerror_not_handle_error
153162

154163
# skip import errors but not attribute errors
155-
def handle_error(name, e):
164+
def handle_error(name: str, e: Exception) -> None:
156165
if not isinstance(e, ImportError):
157166
raise e
158167

159168
with raises(AttributeError):
160169
scan(attributeerror_not_handle_error, handle_error=handle_error)
161170

162171

163-
def test_package_in_zipped():
172+
def test_package_in_zipped() -> None:
164173
with zip_file_in_sys_path():
165-
import packageinzipped
174+
import packageinzipped # type: ignore
166175

167176
scan(packageinzipped)
168177

169178
assert fixtures.calls == 1
170179

171180

172-
def test_module_in_zipped():
181+
def test_module_in_zipped() -> None:
173182
with zip_file_in_sys_path():
174-
import moduleinzipped
183+
import moduleinzipped # type: ignore
175184

176185
scan(moduleinzipped)
177186

0 commit comments

Comments
 (0)