Skip to content

Commit 3266f22

Browse files
committed
feat: Implement force_inspection option in the loader API
1 parent 02b2d7c commit 3266f22

File tree

2 files changed

+86
-33
lines changed

2 files changed

+86
-33
lines changed

src/griffe/loader.py

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from griffe.extensions.base import Extensions, load_extensions
3030
from griffe.finder import ModuleFinder, NamespacePackage, Package
3131
from griffe.git import tmp_worktree
32+
from griffe.importer import dynamic_import
3233
from griffe.logger import get_logger
3334
from griffe.merger import merge_stubs
3435
from griffe.stats import stats
@@ -55,6 +56,7 @@ def __init__(
5556
lines_collection: LinesCollection | None = None,
5657
modules_collection: ModulesCollection | None = None,
5758
allow_inspection: bool = True,
59+
force_inspection: bool = False,
5860
store_source: bool = True,
5961
) -> None:
6062
"""Initialize the loader.
@@ -81,6 +83,8 @@ def __init__(
8183
"""Collection of modules."""
8284
self.allow_inspection: bool = allow_inspection
8385
"""Whether to allow inspecting (importing) modules for which we can't find sources."""
86+
self.force_inspection: bool = force_inspection
87+
"""Whether to force inspecting (importing) modules, even when sources were found."""
8488
self.store_source: bool = store_source
8589
"""Whether to store source code in the lines collection."""
8690
self.finder: ModuleFinder = ModuleFinder(search_paths)
@@ -159,47 +163,69 @@ def load(
159163
# TODO: Remove at some point.
160164
if objspec is None and module is None:
161165
raise TypeError("load() missing 1 required positional argument: 'objspec'")
166+
162167
if objspec is None:
163168
objspec = module
164169
warnings.warn(
165170
"Parameter 'module' was renamed 'objspec' and made positional-only.",
166171
DeprecationWarning,
167172
stacklevel=2,
168173
)
174+
169175
obj_path: str
170-
if objspec in _builtin_modules:
171-
logger.debug(f"{objspec} is a builtin module")
172-
if self.allow_inspection:
173-
logger.debug(f"Inspecting {objspec}")
174-
obj_path = objspec # type: ignore[assignment]
175-
top_module = self._inspect_module(objspec) # type: ignore[arg-type]
176-
self.modules_collection.set_member(top_module.path, top_module)
177-
obj = self.modules_collection.get_member(obj_path)
178-
self.extensions.call("on_package_loaded", pkg=obj)
179-
return obj
180-
raise LoadingError("Cannot load builtin module without inspection")
176+
package = None
177+
top_module = None
178+
179+
# We always start by searching paths on the disk,
180+
# even if inspection is forced.
181+
logger.debug(f"Searching path(s) for {objspec}")
181182
try:
182183
obj_path, package = self.finder.find_spec(
183184
objspec, # type: ignore[arg-type]
184185
try_relative_path=try_relative_path,
185186
find_stubs_package=find_stubs_package,
186187
)
187188
except ModuleNotFoundError:
188-
logger.debug(f"Could not find {objspec}")
189-
if self.allow_inspection:
190-
logger.debug(f"Trying inspection on {objspec}")
191-
obj_path = objspec # type: ignore[assignment]
192-
top_module = self._inspect_module(objspec) # type: ignore[arg-type]
193-
self.modules_collection.set_member(top_module.path, top_module)
194-
else:
189+
# If we couldn't find paths on disk and inspection is disabled,
190+
# re-raise ModuleNotFoundError.
191+
logger.debug(f"Could not find path for {objspec} on disk")
192+
if not (self.allow_inspection or self.force_inspection):
195193
raise
196-
else:
197-
logger.debug(f"Found {objspec}: loading")
194+
195+
# Otherwise we try to dynamically import the top-level module.
196+
obj_path = str(objspec)
197+
top_module_name = obj_path.split(".", 1)[0]
198+
logger.debug(f"Trying to dynamically import {top_module_name}")
199+
top_module_object = dynamic_import(top_module_name, self.finder.search_paths)
200+
198201
try:
199-
top_module = self._load_package(package, submodules=submodules)
200-
except LoadingError as error:
201-
logger.exception(str(error)) # noqa: TRY401
202-
raise
202+
top_module_path = top_module_object.__path__
203+
except AttributeError:
204+
# If the top-level module has no `__path__`, we inspect it as-is,
205+
# and do not try to recurse into submodules (there shouldn't be any in builtin/compiled modules).
206+
logger.debug(f"Module {top_module_name} has no paths set (built-in module?). Inspecting it as-is.")
207+
top_module = self._inspect_module(top_module_name)
208+
self.modules_collection.set_member(top_module.path, top_module)
209+
obj = self.modules_collection.get_member(obj_path)
210+
self.extensions.call("on_package_loaded", pkg=obj)
211+
return obj
212+
213+
# We found paths, and use them to build our intermediate Package or NamespacePackage struct.
214+
logger.debug(f"Module {top_module_name} has paths set: {top_module_path}")
215+
if len(top_module_path) > 1:
216+
package = NamespacePackage(top_module_name, top_module_path)
217+
else:
218+
package = Package(top_module_name, top_module_path[0])
219+
220+
# We have an intermediate package, and an object path: we're ready to load.
221+
logger.debug(f"Found {objspec}: loading")
222+
try:
223+
top_module = self._load_package(package, submodules=submodules)
224+
except LoadingError as error:
225+
logger.exception(str(error)) # noqa: TRY401
226+
raise
227+
228+
# Package is loaded, we now retrieve the initially requested object and return it.
203229
obj = self.modules_collection.get_member(obj_path)
204230
self.extensions.call("on_package_loaded", pkg=obj)
205231
return obj
@@ -534,9 +560,10 @@ def _load_module_path(
534560
logger.debug(f"Loading path {module_path}")
535561
if isinstance(module_path, list):
536562
module = self._create_module(module_name, module_path)
563+
elif self.force_inspection:
564+
module = self._inspect_module(module_name, module_path, parent)
537565
elif module_path.suffix in {".py", ".pyi"}:
538-
code = module_path.read_text(encoding="utf8")
539-
module = self._visit_module(code, module_name, module_path, parent)
566+
module = self._visit_module(module_name, module_path, parent)
540567
elif self.allow_inspection:
541568
module = self._inspect_module(module_name, module_path, parent)
542569
else:
@@ -559,7 +586,7 @@ def _load_submodule(self, module: Module, subparts: tuple[str, ...], subpath: Pa
559586
except UnimportableModuleError as error:
560587
# NOTE: Why don't we load submodules when there's no init module in their folder?
561588
# Usually when a folder with Python files does not have an __init__.py module,
562-
# it's because the Python files are scripts, that should never be imported.
589+
# it's because the Python files are scripts that should never be imported.
563590
# Django has manage.py somewhere for example, in a folder without init module.
564591
# This script isn't part of the Python API, as it's meant to be called on the CLI exclusively
565592
# (at least it was the case a few years ago when I was still using Django).
@@ -590,11 +617,13 @@ def _load_submodule(self, module: Module, subparts: tuple[str, ...], subpath: Pa
590617
logger.debug(str(error))
591618
else:
592619
if submodule_name in parent_module.members:
593-
logger.debug(
594-
f"Submodule '{submodule.path}' is shadowing the member at the same path. "
595-
"We recommend renaming the member or the submodule (for example prefixing it with `_`), "
596-
"see https://mkdocstrings.github.io/griffe/best_practices/#avoid-member-submodule-name-shadowing.",
597-
)
620+
member = parent_module.members[submodule_name]
621+
if member.is_alias or not member.is_module:
622+
logger.debug(
623+
f"Submodule '{submodule.path}' is shadowing the member at the same path. "
624+
"We recommend renaming the member or the submodule (for example prefixing it with `_`), "
625+
"see https://mkdocstrings.github.io/griffe/best_practices/#avoid-member-submodule-name-shadowing.",
626+
)
598627
parent_module.set_member(submodule_name, submodule)
599628

600629
def _create_module(self, module_name: str, module_path: Path | list[Path]) -> Module:
@@ -605,7 +634,8 @@ def _create_module(self, module_name: str, module_path: Path | list[Path]) -> Mo
605634
modules_collection=self.modules_collection,
606635
)
607636

608-
def _visit_module(self, code: str, module_name: str, module_path: Path, parent: Module | None = None) -> Module:
637+
def _visit_module(self, module_name: str, module_path: Path, parent: Module | None = None) -> Module:
638+
code = module_path.read_text(encoding="utf8")
609639
if self.store_source:
610640
self.lines_collection[module_path] = code.splitlines(keepends=False)
611641
start = datetime.now(tz=timezone.utc)
@@ -628,6 +658,8 @@ def _inspect_module(self, module_name: str, filepath: Path | None = None, parent
628658
for prefix in self.ignored_modules:
629659
if module_name.startswith(prefix):
630660
raise ImportError(f"Ignored module '{module_name}'")
661+
if self.store_source and filepath and filepath.suffix in {".py", ".pyi"}:
662+
self.lines_collection[filepath] = filepath.read_text(encoding="utf8").splitlines(keepends=False)
631663
start = datetime.now(tz=timezone.utc)
632664
try:
633665
module = inspect(
@@ -699,6 +731,7 @@ def load(
699731
lines_collection: LinesCollection | None = None,
700732
modules_collection: ModulesCollection | None = None,
701733
allow_inspection: bool = True,
734+
force_inspection: bool = False,
702735
store_source: bool = True,
703736
find_stubs_package: bool = False,
704737
# TODO: Remove at some point.
@@ -739,6 +772,7 @@ def load(
739772
lines_collection: A collection of source code lines.
740773
modules_collection: A collection of modules.
741774
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
775+
force_inspection: Whether to force using dynamic analysis when loading data.
742776
store_source: Whether to store code source in the lines collection.
743777
find_stubs_package: Whether to search for stubs-only package.
744778
If both the package and its stubs are found, they'll be merged together.
@@ -761,6 +795,7 @@ def load(
761795
lines_collection=lines_collection,
762796
modules_collection=modules_collection,
763797
allow_inspection=allow_inspection,
798+
force_inspection=force_inspection,
764799
store_source=store_source,
765800
)
766801
result = loader.load(
@@ -790,6 +825,7 @@ def load_git(
790825
lines_collection: LinesCollection | None = None,
791826
modules_collection: ModulesCollection | None = None,
792827
allow_inspection: bool = True,
828+
force_inspection: bool = False,
793829
find_stubs_package: bool = False,
794830
# TODO: Remove at some point.
795831
module: str | Path | None = None,
@@ -825,6 +861,7 @@ def load_git(
825861
lines_collection: A collection of source code lines.
826862
modules_collection: A collection of modules.
827863
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
864+
force_inspection: Whether to force using dynamic analysis when loading data.
828865
find_stubs_package: Whether to search for stubs-only package.
829866
If both the package and its stubs are found, they'll be merged together.
830867
If only the stubs are found, they'll be used as the package itself.
@@ -856,6 +893,7 @@ def load_git(
856893
lines_collection=lines_collection,
857894
modules_collection=modules_collection,
858895
allow_inspection=allow_inspection,
896+
force_inspection=force_inspection,
859897
find_stubs_package=find_stubs_package,
860898
# TODO: Remove at some point.
861899
module=module,

tests/test_loader.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from griffe.expressions import ExprName
1212
from griffe.loader import GriffeLoader
1313
from griffe.tests import temporary_pyfile, temporary_pypackage, temporary_visited_package
14+
from tests.helpers import clear_sys_modules
1415

1516
if TYPE_CHECKING:
1617
from pathlib import Path
@@ -449,3 +450,17 @@ def test_side_loading_sibling_private_module(wildcard: bool, external: bool | No
449450
assert "foo" in package.members
450451
assert package["foo"].is_alias
451452
assert not package["foo"].resolved
453+
454+
455+
def test_forcing_inspection() -> None:
456+
"""Load a package with forced dynamic analysis."""
457+
with temporary_pypackage("pkg", {"__init__.py": "a = 0", "mod.py": "b = 1"}) as pkg:
458+
static_loader = GriffeLoader(force_inspection=False, search_paths=[pkg.tmpdir])
459+
dynamic_loader = GriffeLoader(force_inspection=True, search_paths=[pkg.tmpdir])
460+
static_package = static_loader.load("pkg")
461+
dynamic_package = dynamic_loader.load("pkg")
462+
for name in static_package.members:
463+
assert name in dynamic_package.members
464+
for name in static_package["mod"].members:
465+
assert name in dynamic_package["mod"].members
466+
clear_sys_modules("pkg")

0 commit comments

Comments
 (0)