diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1aa1d1..5469b3a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,8 +24,8 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: "actions/checkout@v6" + - uses: "actions/setup-python@v6" with: python-version: "${{ matrix.python-version }}" - name: "Install dependencies" diff --git a/importscan/__init__.py b/importscan/__init__.py index 94b43b8..952ed7b 100644 --- a/importscan/__init__.py +++ b/importscan/__init__.py @@ -1 +1,3 @@ -from .scan import scan # noqa +from .scan import scan + +__all__ = ("scan",) diff --git a/importscan/scan.py b/importscan/scan.py index e2d848a..f081af5 100644 --- a/importscan/scan.py +++ b/importscan/scan.py @@ -1,8 +1,22 @@ -from pkgutil import iter_modules +from __future__ import annotations + import sys +from pkgutil import iter_modules +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from collections.abc import Callable, Generator, Iterable + from importlib.abc import Loader + from types import ModuleType + from typing_extensions import TypeIs + from importscan.types import IgnoreModule, ModuleInfo, StrOrBytesPath -def scan(package, ignore=None, handle_error=None): + +def scan( + package: ModuleType, + ignore: Iterable[IgnoreModule] | IgnoreModule | None = None, + handle_error: Callable[[str, Exception], object] | None = None, +) -> None: """Scan a package by importing it. A framework can provide registration decorators: a decorator that @@ -99,22 +113,39 @@ def handle_error(name, e): is_ignored=is_ignored, handle_error=handle_error, ): - try: - loader = importer.find_spec(modname).loader - except AttributeError: - # zipimport.zipimporter doesn't have find_spec - loader = importer.find_module(modname) + # FIXME: Add support for MetaPathFinder? But how would that work? + # What path do we pass in to get the correct result? + # We probably would need to remember the value of path we passed + # into iter_modules for submodules/subpackages. There's also + # the additional issue that not all finders will implement the + # non-standard iter_modules method, but without it there's + # no way to list all of the modules. Also since walk_packages + # already imports all of the packages, why are we importing + # them again here? Shouldn't we only import modules here? + # Also why do we do only use `import_module` here, but not + # in `walk_packages`? Doesn't that mean that the additional + # check in `import_module` doesn't do anything for packages? + loader = importer.find_spec(modname).loader # type: ignore + assert loader is not None + try: import_module(modname, loader, handle_error) finally: - if hasattr(loader, "file") and hasattr(loader.file, "close"): - loader.file.close() - - -def import_module(modname, loader, handle_error): + if hasattr(loader, "file") and hasattr( + loader.file, # pyright: ignore[reportAttributeAccessIssue] + "close", + ): + loader.file.close() # pyright: ignore[reportAttributeAccessIssue] + + +def import_module( + modname: str, + loader: Loader, + handle_error: Callable[[str, Exception], object] | None, +) -> None: get_filename = getattr(loader, "get_filename", None) if get_filename is None: - get_filename = loader._get_filename + get_filename = loader._get_filename # type: ignore[attr-defined] try: fn = get_filename(modname) except TypeError: @@ -135,10 +166,13 @@ def import_module(modname, loader, handle_error): raise -def get_is_ignored(package, ignore): +def get_is_ignored( + package: ModuleType, ignore: Iterable[IgnoreModule] | IgnoreModule | None +) -> Callable[[str], bool]: + pkg_name = package.__name__ - def is_nonstr_iter(v): + def is_nonstr_iter(v: object) -> TypeIs[Iterable[IgnoreModule]]: if isinstance(v, str): # pragma: no cover return False return hasattr(v, "__iter__") @@ -157,7 +191,7 @@ def is_nonstr_iter(v): # functions, e.g. re.compile('pattern').search callable_ignores = [ign for ign in ignore if callable(ign)] - def is_ignored(fullname): + def is_ignored(fullname: str) -> bool: for ign in rel_ignores: if fullname.startswith(pkg_name + ign): return True @@ -165,15 +199,20 @@ def is_ignored(fullname): # non-leading-dotted name absolute object name if fullname.startswith(ign): return True - for ign in callable_ignores: - if ign(fullname): + for ign_fn in callable_ignores: + if ign_fn(fullname): return True return False return is_ignored -def walk_packages(path=None, prefix="", is_ignored=None, handle_error=None): +def walk_packages( + path: Iterable[StrOrBytesPath] | None = None, + prefix: str = "", + is_ignored: Callable[[str], bool] | None = None, + handle_error: Callable[[str, Exception], object] | None = None, +) -> Generator[ModuleInfo]: """Yields (module_finder, name, ispkg) for all modules recursively on path, or, if path is ``None``, all accessible modules. @@ -205,10 +244,11 @@ def walk_packages(path=None, prefix="", is_ignored=None, handle_error=None): """ - def seen(p, m={}): + def seen(p: str, m: set[str] = set()) -> bool: if p in m: # pragma: no cover return True - m[p] = True + m.add(p) + return False # iter_modules is nonrecursive for module_finder, name, ispkg in iter_modules(path, prefix): @@ -235,6 +275,6 @@ def seen(p, m={}): path = getattr(sys.modules[name], "__path__", None) or [] # don't traverse path items we've seen before - path = [p for p in path if not seen(p)] + path = [p for p in path if not seen(p)] # pyright: ignore yield from walk_packages(path, name + ".", is_ignored, handle_error) diff --git a/importscan/tests/fixtures/__init__.py b/importscan/tests/fixtures/__init__.py index decaa39..bd20a03 100644 --- a/importscan/tests/fixtures/__init__.py +++ b/importscan/tests/fixtures/__init__.py @@ -1,11 +1,13 @@ +from __future__ import annotations + calls = 0 -def call(): +def call() -> None: global calls calls += 1 -def reset(): +def reset() -> None: global calls calls = 0 diff --git a/importscan/tests/test_importscan.py b/importscan/tests/test_importscan.py index 5ff50f4..5db700a 100644 --- a/importscan/tests/test_importscan.py +++ b/importscan/tests/test_importscan.py @@ -1,24 +1,32 @@ -import sys -import re -import os +from __future__ import annotations + import contextlib +import os +import re +import sys +from typing import TYPE_CHECKING + +import pytest +from pytest import raises from importscan import scan from . import fixtures -from pytest import raises + +if TYPE_CHECKING: + from collections.abc import Generator # note that due to the nature of imports, we need to have a unique fixture # for each test @contextlib.contextmanager -def with_entry_in_sys_path(entry): +def with_entry_in_sys_path(entry: str) -> Generator[None]: """Context manager that temporarily puts an entry at head of sys.path""" sys.path.insert(0, entry) yield sys.path.remove(entry) -def zip_file_in_sys_path(): +def zip_file_in_sys_path() -> contextlib.AbstractContextManager[None]: """Context manager that puts zipped.zip at head of sys.path""" zip_pkg_path = os.path.join( os.path.dirname(__file__), "fixtures", "zipped.zip" @@ -26,11 +34,12 @@ def zip_file_in_sys_path(): return with_entry_in_sys_path(zip_pkg_path) -def setup_function(function): +@pytest.fixture(autouse=True, scope="function") +def reset_fixtures() -> None: fixtures.reset() -def test_empty_package(): +def test_empty_package() -> None: from .fixtures import empty_package scan(empty_package) @@ -38,7 +47,7 @@ def test_empty_package(): assert fixtures.calls == 1 -def test_module(): +def test_module() -> None: from .fixtures import module scan(module) @@ -46,7 +55,7 @@ def test_module(): assert fixtures.calls == 1 -def test_package(): +def test_package() -> None: from .fixtures import package scan(package) @@ -54,7 +63,7 @@ def test_package(): assert fixtures.calls == 1 -def test_empty_subpackage(): +def test_empty_subpackage() -> None: from .fixtures import empty_subpackage scan(empty_subpackage) @@ -62,7 +71,7 @@ def test_empty_subpackage(): assert fixtures.calls == 1 -def test_subpackage(): +def test_subpackage() -> None: from .fixtures import subpackage scan(subpackage) @@ -70,7 +79,7 @@ def test_subpackage(): assert fixtures.calls == 1 -def test_ignore_module_relative(): +def test_ignore_module_relative() -> None: from .fixtures import ignore_module scan(ignore_module, ignore=[".module"]) @@ -78,7 +87,7 @@ def test_ignore_module_relative(): assert fixtures.calls == 0 -def test_ignore_module_absolute(): +def test_ignore_module_absolute() -> None: from .fixtures import ignore_module_absolute scan( @@ -89,7 +98,7 @@ def test_ignore_module_absolute(): assert fixtures.calls == 0 -def test_ignore_module_function(): +def test_ignore_module_function() -> None: from .fixtures import ignore_module_function scan(ignore_module_function, ignore=re.compile("module$").search) @@ -97,7 +106,7 @@ def test_ignore_module_function(): assert fixtures.calls == 0 -def test_ignore_subpackage_relative(): +def test_ignore_subpackage_relative() -> None: from .fixtures import ignore_subpackage scan(ignore_subpackage, ignore=[".sub"]) @@ -105,7 +114,7 @@ def test_ignore_subpackage_relative(): assert fixtures.calls == 0 -def test_ignore_subpackage_function(): +def test_ignore_subpackage_function() -> None: from .fixtures import ignore_subpackage_function scan(ignore_subpackage_function, ignore=re.compile("sub$").search) @@ -113,7 +122,7 @@ def test_ignore_subpackage_function(): assert fixtures.calls == 0 -def test_ignore_subpackage_module_relative(): +def test_ignore_subpackage_module_relative() -> None: from .fixtures import ignore_subpackage_module scan(ignore_subpackage_module, ignore=[".sub.module"]) @@ -121,25 +130,25 @@ def test_ignore_subpackage_module_relative(): assert fixtures.calls == 0 -def test_importerror(): +def test_importerror() -> None: from .fixtures import importerror with raises(ImportError): scan(importerror) -def test_attributeerror(): +def test_attributeerror() -> None: from .fixtures import attributeerror with raises(AttributeError): scan(attributeerror) -def test_importerror_handle_error(): +def test_importerror_handle_error() -> None: from .fixtures import importerror_handle_error # skip import errors - def handle_error(name, e): + def handle_error(name: str, e: Exception) -> None: if not isinstance(e, ImportError): raise e @@ -148,11 +157,11 @@ def handle_error(name, e): assert fixtures.calls == 1 -def test_attributeerror_not_handle_error(): +def test_attributeerror_not_handle_error() -> None: from .fixtures import attributeerror_not_handle_error # skip import errors but not attribute errors - def handle_error(name, e): + def handle_error(name: str, e: Exception) -> None: if not isinstance(e, ImportError): raise e @@ -160,18 +169,18 @@ def handle_error(name, e): scan(attributeerror_not_handle_error, handle_error=handle_error) -def test_package_in_zipped(): +def test_package_in_zipped() -> None: with zip_file_in_sys_path(): - import packageinzipped + import packageinzipped # type: ignore scan(packageinzipped) assert fixtures.calls == 1 -def test_module_in_zipped(): +def test_module_in_zipped() -> None: with zip_file_in_sys_path(): - import moduleinzipped + import moduleinzipped # type: ignore scan(moduleinzipped) diff --git a/importscan/types.py b/importscan/types.py new file mode 100644 index 0000000..4752127 --- /dev/null +++ b/importscan/types.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from importlib.machinery import ModuleSpec +from os import PathLike +from types import ModuleType +from typing import Protocol, TypeAlias + + +class MetaPathFinderProtocol(Protocol): + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = ..., + /, + ) -> ModuleSpec | None: ... + + +class PathEntryFinderProtocol(Protocol): + def find_spec( + self, fullname: str, target: ModuleType | None = ..., / + ) -> ModuleSpec | None: ... + + +IgnoreModuleCallback: TypeAlias = Callable[[str], object] +IgnoreModule: TypeAlias = str | IgnoreModuleCallback +ModuleFinder: TypeAlias = MetaPathFinderProtocol | PathEntryFinderProtocol +ModuleInfo: TypeAlias = tuple[ModuleFinder, str, bool] +StrOrBytesPath: TypeAlias = PathLike[str] | PathLike[bytes] | str | bytes diff --git a/pyproject.toml b/pyproject.toml index cc1a7df..f5609da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ test = ["pytest >= 8", "pytest-env"] coverage = ["pytest-cov"] lint = ["black", "flake8", "flake8-pyproject"] docs = ["sphinx"] +mypy = ["mypy", "pytest"] +pyright = ["pyright", "pytest"] [tool.setuptools.packages] find = {} @@ -51,7 +53,7 @@ addopts = ["-vv"] env = ["RUN_ENV=test"] [tool.coverage.run] -omit = ["importscan/tests/*"] +omit = ["importscan/tests/*", "importscan/types.py"] source = ["importscan"] [tool.coverage.report] @@ -59,9 +61,23 @@ show_missing = true [tool.flake8] show-source = true -ignore = ["E203", "W503"] +ignore = ["E203", "E501", "E704", "W503", "W504"] max-line-length = 88 +[tool.mypy] +python_version = "3.10" +strict = true +warn_unreachable = true + +[[tool.mypy.overrides]] +module = "importscan.tests.fixtures.*" +ignore_errors = true + +[tool.pyright] +exclude = [ + "**/tests/fixtures/**", +] + [tool.tox] requires = ["tox>=4"] env_list = [ @@ -74,6 +90,8 @@ env_list = [ "coverage", "pre-commit", "docs", + "mypy", + "pyright" ] skip_missing_interpreters = true @@ -94,7 +112,7 @@ commands = [["pytest", "{posargs:importscan}"]] base_python = ["python3"] extras = ["test", "coverage"] commands = [ - ["pytest", "--cov", "--cov-fail-under=85", "{posargs:importscan}"], + ["pytest", "--cov", "--cov-fail-under=88", "{posargs:importscan}"], ] [tool.tox.env.pre-commit] @@ -111,3 +129,25 @@ extras = ["docs"] commands = [ ["sphinx-build", "-b", "doctest", "doc", "{env_tmp_dir}"], ] + +[tool.tox.env.mypy] +base_python = ["python3"] +extras = ["mypy"] +commands = [ + ["mypy", "-p", "importscan", "--python-version", "3.10"], + ["mypy", "-p", "importscan", "--python-version", "3.11"], + ["mypy", "-p", "importscan", "--python-version", "3.12"], + ["mypy", "-p", "importscan", "--python-version", "3.13"], + ["mypy", "-p", "importscan", "--python-version", "3.14"], +] + +[tool.tox.env.pyright] +base_python = ["python3"] +extras = ["pyright"] +commands = [ + ["pyright", "importscan", "--pythonversion", "3.10"], + ["pyright", "importscan", "--pythonversion", "3.11"], + ["pyright", "importscan", "--pythonversion", "3.12"], + ["pyright", "importscan", "--pythonversion", "3.13"], + ["pyright", "importscan", "--pythonversion", "3.14"], +]