2424from sphinx .util .inspect import safe_getattr
2525
2626if TYPE_CHECKING :
27- from collections .abc import Iterator
27+ from collections .abc import Iterator , Sequence
2828
2929 from sphinx .application import Sphinx
3030
@@ -76,34 +76,76 @@ def _load_modules(mod_name: str, ignored_module_exps: list[re.Pattern[str]]) ->
7676 :raises ImportError: If the module indicated by ``mod_name`` could not be
7777 loaded.
7878 """
79- modules : set [str ] = set ()
79+ if any (exp .match (mod_name ) for exp in ignored_module_exps ):
80+ return set ()
8081
81- for exp in ignored_module_exps :
82- if exp .match (mod_name ):
83- return modules
84-
85- # this can raise an exception but it's the responsibility of the caller to
86- # handle this
82+ # This can raise an exception, which must be handled by the caller.
8783 mod = import_module (mod_name )
84+ modules = {mod_name }
85+ if mod .__spec__ is None :
86+ return modules
8887
89- modules .add (mod_name )
90-
91- for sub_mod_info in pkgutil .iter_modules (mod .__path__ ):
92- if sub_mod_info .name == '__main__' :
88+ search_locations = mod .__spec__ .submodule_search_locations
89+ for (_ , sub_mod_name , sub_mod_ispkg ) in pkgutil .iter_modules (search_locations ):
90+ if sub_mod_name == '__main__' :
9391 continue
9492
95- if sub_mod_info . ispkg :
96- modules |= _load_modules (f'{ mod_name } .{ sub_mod_info . name } ' , ignored_module_exps )
93+ if sub_mod_ispkg :
94+ modules |= _load_modules (f'{ mod_name } .{ sub_mod_name } ' , ignored_module_exps )
9795 else :
98- for exp in ignored_module_exps :
99- if exp .match (sub_mod_info .name ):
100- continue
101-
102- modules .add (f'{ mod_name } .{ sub_mod_info .name } ' )
96+ if any (exp .match (sub_mod_name ) for exp in ignored_module_exps ):
97+ continue
98+ modules .add (f'{ mod_name } .{ sub_mod_name } ' )
10399
104100 return modules
105101
106102
103+ def _determine_py_coverage_modules (
104+ coverage_modules : Sequence [str ],
105+ seen_modules : set [str ],
106+ ignored_module_exps : list [re .Pattern [str ]],
107+ py_undoc : dict [str , dict [str , Any ]],
108+ ) -> list [str ]:
109+ """Return a sorted list of modules to check for coverage.
110+
111+ Figure out which of the two operating modes to use:
112+
113+ - If 'coverage_modules' is not specified, we check coverage for all modules
114+ seen in the documentation tree. Any objects found in these modules that are
115+ not documented will be noted. This will therefore only identify missing
116+ objects, but it requires no additional configuration.
117+
118+ - If 'coverage_modules' is specified, we check coverage for all modules
119+ specified in this configuration value. Any objects found in these modules
120+ that are not documented will be noted. In addition, any objects from other
121+ modules that are documented will be noted. This will therefore identify both
122+ missing modules and missing objects, but it requires manual configuration.
123+ """
124+
125+ if not coverage_modules :
126+ return sorted (seen_modules )
127+
128+ modules = set ()
129+ for mod_name in coverage_modules :
130+ try :
131+ modules |= _load_modules (mod_name , ignored_module_exps )
132+ except ImportError as err :
133+ # TODO(stephenfin): Define a subtype for all logs in this module
134+ logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
135+ py_undoc [mod_name ] = {'error' : err }
136+ continue
137+
138+ # if there are additional modules then we warn (but still scan)
139+ additional_modules = set (seen_modules ) - modules
140+ if additional_modules :
141+ logger .warning (
142+ __ ('the following modules are documented but were not specified '
143+ 'in coverage_modules: %s' ),
144+ ', ' .join (additional_modules ),
145+ )
146+ return sorted (modules )
147+
148+
107149class CoverageBuilder (Builder ):
108150 """
109151 Evaluates coverage of code in the documentation.
@@ -129,7 +171,6 @@ def init(self) -> None:
129171 for (name , exps ) in self .config .coverage_ignore_c_items .items ():
130172 self .c_ignorexps [name ] = compile_regex_list ('coverage_ignore_c_items' ,
131173 exps )
132- self .module_names = self .config .coverage_modules
133174 self .mod_ignorexps = compile_regex_list ('coverage_ignore_modules' ,
134175 self .config .coverage_ignore_modules )
135176 self .cls_ignorexps = compile_regex_list ('coverage_ignore_classes' ,
@@ -207,45 +248,15 @@ def ignore_pyobj(self, full_name: str) -> bool:
207248 )
208249
209250 def build_py_coverage (self ) -> None :
210- seen_objects = self .env .domaindata ['py' ]['objects' ]
211- seen_modules = self .env .domaindata ['py' ]['modules' ]
251+ seen_objects = set ( self .env .domaindata ['py' ]['objects' ])
252+ seen_modules = set ( self .env .domaindata ['py' ]['modules' ])
212253
213254 skip_undoc = self .config .coverage_skip_undoc_in_source
214255
215- # Figure out which of the two operating modes to use:
216- #
217- # - If 'coverage_modules' is not specified, we check coverage for all modules
218- # seen in the documentation tree. Any objects found in these modules that are
219- # not documented will be noted. This will therefore only identify missing
220- # objects but it requires no additional configuration.
221- # - If 'coverage_modules' is specified, we check coverage for all modules
222- # specified in this configuration value. Any objects found in these modules
223- # that are not documented will be noted. In addition, any objects from other
224- # modules that are documented will be noted. This will therefore identify both
225- # missing modules and missing objects but it requires manual configuration.
226- if not self .module_names :
227- modules = set (seen_modules )
228- else :
229- modules = set ()
230- for mod_name in self .module_names :
231- try :
232- modules |= _load_modules (mod_name , self .mod_ignorexps )
233- except ImportError as err :
234- # TODO(stephenfin): Define a subtype for all logs in this module
235- logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
236- self .py_undoc [mod_name ] = {'error' : err }
237- continue
238-
239- # if there are additional modules then we warn (but still scan)
240- additional_modules = set (seen_modules ) - modules
241- if additional_modules :
242- logger .warning (
243- __ ('the following modules are documented but were not specified '
244- 'in coverage_modules: %s' ),
245- ', ' .join (additional_modules ),
246- )
247-
248- for mod_name in sorted (modules ):
256+ modules = _determine_py_coverage_modules (
257+ self .config .coverage_modules , seen_modules , self .mod_ignorexps , self .py_undoc ,
258+ )
259+ for mod_name in modules :
249260 ignore = False
250261 for exp in self .mod_ignorexps :
251262 if exp .match (mod_name ):
@@ -461,7 +472,7 @@ def finish(self) -> None:
461472
462473def setup (app : Sphinx ) -> dict [str , Any ]:
463474 app .add_builder (CoverageBuilder )
464- app .add_config_value ('coverage_modules' , [] , False )
475+ app .add_config_value ('coverage_modules' , () , False , [ tuple , list , set ] )
465476 app .add_config_value ('coverage_ignore_modules' , [], False )
466477 app .add_config_value ('coverage_ignore_functions' , [], False )
467478 app .add_config_value ('coverage_ignore_classes' , [], False )
0 commit comments