2929from griffe .extensions .base import Extensions , load_extensions
3030from griffe .finder import ModuleFinder , NamespacePackage , Package
3131from griffe .git import tmp_worktree
32+ from griffe .importer import dynamic_import
3233from griffe .logger import get_logger
3334from griffe .merger import merge_stubs
3435from 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 ,
0 commit comments