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 from sphinx .util .typing import ExtensionMetadata
@@ -77,34 +77,76 @@ def _load_modules(mod_name: str, ignored_module_exps: list[re.Pattern[str]]) ->
7777 :raises ImportError: If the module indicated by ``mod_name`` could not be
7878 loaded.
7979 """
80- modules : set [str ] = set ()
80+ if any (exp .match (mod_name ) for exp in ignored_module_exps ):
81+ return set ()
8182
82- for exp in ignored_module_exps :
83- if exp .match (mod_name ):
84- return modules
85-
86- # this can raise an exception but it's the responsibility of the caller to
87- # handle this
83+ # This can raise an exception, which must be handled by the caller.
8884 mod = import_module (mod_name )
85+ modules = {mod_name }
86+ if mod .__spec__ is None :
87+ return modules
8988
90- modules .add (mod_name )
91-
92- for sub_mod_info in pkgutil .iter_modules (mod .__path__ ):
93- if sub_mod_info .name == '__main__' :
89+ search_locations = mod .__spec__ .submodule_search_locations
90+ for (_ , sub_mod_name , sub_mod_ispkg ) in pkgutil .iter_modules (search_locations ):
91+ if sub_mod_name == '__main__' :
9492 continue
9593
96- if sub_mod_info . ispkg :
97- modules |= _load_modules (f'{ mod_name } .{ sub_mod_info . name } ' , ignored_module_exps )
94+ if sub_mod_ispkg :
95+ modules |= _load_modules (f'{ mod_name } .{ sub_mod_name } ' , ignored_module_exps )
9896 else :
99- for exp in ignored_module_exps :
100- if exp .match (sub_mod_info .name ):
101- continue
102-
103- modules .add (f'{ mod_name } .{ sub_mod_info .name } ' )
97+ if any (exp .match (sub_mod_name ) for exp in ignored_module_exps ):
98+ continue
99+ modules .add (f'{ mod_name } .{ sub_mod_name } ' )
104100
105101 return modules
106102
107103
104+ def _determine_py_coverage_modules (
105+ coverage_modules : Sequence [str ],
106+ seen_modules : set [str ],
107+ ignored_module_exps : list [re .Pattern [str ]],
108+ py_undoc : dict [str , dict [str , Any ]],
109+ ) -> list [str ]:
110+ """Return a sorted list of modules to check for coverage.
111+
112+ Figure out which of the two operating modes to use:
113+
114+ - If 'coverage_modules' is not specified, we check coverage for all modules
115+ seen in the documentation tree. Any objects found in these modules that are
116+ not documented will be noted. This will therefore only identify missing
117+ objects, but it requires no additional configuration.
118+
119+ - If 'coverage_modules' is specified, we check coverage for all modules
120+ specified in this configuration value. Any objects found in these modules
121+ that are not documented will be noted. In addition, any objects from other
122+ modules that are documented will be noted. This will therefore identify both
123+ missing modules and missing objects, but it requires manual configuration.
124+ """
125+
126+ if not coverage_modules :
127+ return sorted (seen_modules )
128+
129+ modules = set ()
130+ for mod_name in coverage_modules :
131+ try :
132+ modules |= _load_modules (mod_name , ignored_module_exps )
133+ except ImportError as err :
134+ # TODO(stephenfin): Define a subtype for all logs in this module
135+ logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
136+ py_undoc [mod_name ] = {'error' : err }
137+ continue
138+
139+ # if there are additional modules then we warn (but still scan)
140+ additional_modules = set (seen_modules ) - modules
141+ if additional_modules :
142+ logger .warning (
143+ __ ('the following modules are documented but were not specified '
144+ 'in coverage_modules: %s' ),
145+ ', ' .join (additional_modules ),
146+ )
147+ return sorted (modules )
148+
149+
108150class CoverageBuilder (Builder ):
109151 """
110152 Evaluates coverage of code in the documentation.
@@ -131,7 +173,6 @@ def init(self) -> None:
131173 for (name , exps ) in self .config .coverage_ignore_c_items .items ():
132174 self .c_ignorexps [name ] = compile_regex_list ('coverage_ignore_c_items' ,
133175 exps )
134- self .module_names = self .config .coverage_modules
135176 self .mod_ignorexps = compile_regex_list ('coverage_ignore_modules' ,
136177 self .config .coverage_ignore_modules )
137178 self .cls_ignorexps = compile_regex_list ('coverage_ignore_classes' ,
@@ -209,45 +250,15 @@ def ignore_pyobj(self, full_name: str) -> bool:
209250 )
210251
211252 def build_py_coverage (self ) -> None :
212- seen_objects = self .env .domaindata ['py' ]['objects' ]
213- seen_modules = self .env .domaindata ['py' ]['modules' ]
253+ seen_objects = set ( self .env .domaindata ['py' ]['objects' ])
254+ seen_modules = set ( self .env .domaindata ['py' ]['modules' ])
214255
215256 skip_undoc = self .config .coverage_skip_undoc_in_source
216257
217- # Figure out which of the two operating modes to use:
218- #
219- # - If 'coverage_modules' is not specified, we check coverage for all modules
220- # seen in the documentation tree. Any objects found in these modules that are
221- # not documented will be noted. This will therefore only identify missing
222- # objects but it requires no additional configuration.
223- # - If 'coverage_modules' is specified, we check coverage for all modules
224- # specified in this configuration value. Any objects found in these modules
225- # that are not documented will be noted. In addition, any objects from other
226- # modules that are documented will be noted. This will therefore identify both
227- # missing modules and missing objects but it requires manual configuration.
228- if not self .module_names :
229- modules = set (seen_modules )
230- else :
231- modules = set ()
232- for mod_name in self .module_names :
233- try :
234- modules |= _load_modules (mod_name , self .mod_ignorexps )
235- except ImportError as err :
236- # TODO(stephenfin): Define a subtype for all logs in this module
237- logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
238- self .py_undoc [mod_name ] = {'error' : err }
239- continue
240-
241- # if there are additional modules then we warn (but still scan)
242- additional_modules = set (seen_modules ) - modules
243- if additional_modules :
244- logger .warning (
245- __ ('the following modules are documented but were not specified '
246- 'in coverage_modules: %s' ),
247- ', ' .join (additional_modules ),
248- )
249-
250- for mod_name in sorted (modules ):
258+ modules = _determine_py_coverage_modules (
259+ self .config .coverage_modules , seen_modules , self .mod_ignorexps , self .py_undoc ,
260+ )
261+ for mod_name in modules :
251262 ignore = False
252263 for exp in self .mod_ignorexps :
253264 if exp .match (mod_name ):
@@ -463,7 +474,7 @@ def finish(self) -> None:
463474
464475def setup (app : Sphinx ) -> ExtensionMetadata :
465476 app .add_builder (CoverageBuilder )
466- app .add_config_value ('coverage_modules' , [] , '' )
477+ app .add_config_value ('coverage_modules' , () , '' , [ tuple , list , set ] )
467478 app .add_config_value ('coverage_ignore_modules' , [], '' )
468479 app .add_config_value ('coverage_ignore_functions' , [], '' )
469480 app .add_config_value ('coverage_ignore_classes' , [], '' )
0 commit comments