99import glob
1010import inspect
1111import pickle
12+ import pkgutil
1213import re
1314import sys
1415from importlib import import_module
@@ -66,6 +67,44 @@ def _add_row(col_widths: list[int], columns: list[str], separator: str) -> Itera
6667 yield _add_line (col_widths , separator )
6768
6869
70+ def _load_modules (mod_name : str , ignored_module_exps : list [re .Pattern [str ]]) -> set [str ]:
71+ """Recursively load all submodules.
72+
73+ :param mod_name: The name of a module to load submodules for.
74+ :param ignored_module_exps: A list of regexes for modules to ignore.
75+ :returns: A set of modules names including the provided module name,
76+ ``mod_name``
77+ :raises ImportError: If the module indicated by ``mod_name`` could not be
78+ loaded.
79+ """
80+ modules : set [str ] = set ()
81+
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
88+ mod = import_module (mod_name )
89+
90+ modules .add (mod_name )
91+
92+ for sub_mod_info in pkgutil .iter_modules (mod .__path__ ):
93+ if sub_mod_info .name == '__main__' :
94+ continue
95+
96+ if sub_mod_info .ispkg :
97+ modules |= _load_modules (f'{ mod_name } .{ sub_mod_info .name } ' , ignored_module_exps )
98+ 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 } ' )
104+
105+ return modules
106+
107+
69108class CoverageBuilder (Builder ):
70109 """
71110 Evaluates coverage of code in the documentation.
@@ -92,6 +131,7 @@ def init(self) -> None:
92131 for (name , exps ) in self .config .coverage_ignore_c_items .items ():
93132 self .c_ignorexps [name ] = compile_regex_list ('coverage_ignore_c_items' ,
94133 exps )
134+ self .module_names = self .config .coverage_modules
95135 self .mod_ignorexps = compile_regex_list ('coverage_ignore_modules' ,
96136 self .config .coverage_ignore_modules )
97137 self .cls_ignorexps = compile_regex_list ('coverage_ignore_classes' ,
@@ -169,11 +209,44 @@ def ignore_pyobj(self, full_name: str) -> bool:
169209 )
170210
171211 def build_py_coverage (self ) -> None :
172- objects = self .env .domaindata ['py' ]['objects' ]
173- modules = self .env .domaindata ['py' ]['modules' ]
212+ seen_objects = self .env .domaindata ['py' ]['objects' ]
213+ seen_modules = self .env .domaindata ['py' ]['modules' ]
174214
175215 skip_undoc = self .config .coverage_skip_undoc_in_source
176216
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+
177250 for mod_name in modules :
178251 ignore = False
179252 for exp in self .mod_ignorexps :
@@ -213,7 +286,7 @@ def build_py_coverage(self) -> None:
213286 continue
214287
215288 if inspect .isfunction (obj ):
216- if full_name not in objects :
289+ if full_name not in seen_objects :
217290 for exp in self .fun_ignorexps :
218291 if exp .match (name ):
219292 break
@@ -229,7 +302,7 @@ def build_py_coverage(self) -> None:
229302 if exp .match (name ):
230303 break
231304 else :
232- if full_name not in objects :
305+ if full_name not in seen_objects :
233306 if skip_undoc and not obj .__doc__ :
234307 continue
235308 # not documented at all
@@ -257,7 +330,7 @@ def build_py_coverage(self) -> None:
257330 full_attr_name = f'{ full_name } .{ attr_name } '
258331 if self .ignore_pyobj (full_attr_name ):
259332 continue
260- if full_attr_name not in objects :
333+ if full_attr_name not in seen_objects :
261334 attrs .append (attr_name )
262335 undocumented_objects .add (full_attr_name )
263336 else :
@@ -391,6 +464,7 @@ def finish(self) -> None:
391464
392465def setup (app : Sphinx ) -> ExtensionMetadata :
393466 app .add_builder (CoverageBuilder )
467+ app .add_config_value ('coverage_modules' , [], '' )
394468 app .add_config_value ('coverage_ignore_modules' , [], '' )
395469 app .add_config_value ('coverage_ignore_functions' , [], '' )
396470 app .add_config_value ('coverage_ignore_classes' , [], '' )
0 commit comments