99import glob
1010import inspect
1111import pickle
12+ import pkgutil
1213import re
1314import sys
1415from importlib import import_module
@@ -65,6 +66,44 @@ def _add_row(col_widths: list[int], columns: list[str], separator: str) -> Itera
6566 yield _add_line (col_widths , separator )
6667
6768
69+ def load_modules (mod_name : str , ignored_module_exps : list [re .Pattern [str ]]) -> set [str ]:
70+ """Recursively load all submodules.
71+
72+ :param mod_name: The name of a module to load submodules for.
73+ :param ignored_module_exps: A list of regexes for modules to ignore.
74+ :returns: A set of modules names including the provided module name,
75+ ``mod_name``
76+ :raises ImportError: If the module indicated by ``mod_name`` could not be
77+ loaded.
78+ """
79+ modules : set [str ] = set ()
80+
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
87+ mod = import_module (mod_name )
88+
89+ modules .add (mod_name )
90+
91+ for sub_mod_info in pkgutil .iter_modules (mod .__path__ ):
92+ if sub_mod_info .name == '__main__' :
93+ continue
94+
95+ if sub_mod_info .ispkg :
96+ modules |= load_modules (f'{ mod_name } .{ sub_mod_info .name } ' , ignored_module_exps )
97+ 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 } ' )
103+
104+ return modules
105+
106+
68107class CoverageBuilder (Builder ):
69108 """
70109 Evaluates coverage of code in the documentation.
@@ -90,6 +129,7 @@ def init(self) -> None:
90129 for (name , exps ) in self .config .coverage_ignore_c_items .items ():
91130 self .c_ignorexps [name ] = compile_regex_list ('coverage_ignore_c_items' ,
92131 exps )
132+ self .module_names = self .config .coverage_modules
93133 self .mod_ignorexps = compile_regex_list ('coverage_ignore_modules' ,
94134 self .config .coverage_ignore_modules )
95135 self .cls_ignorexps = compile_regex_list ('coverage_ignore_classes' ,
@@ -167,11 +207,44 @@ def ignore_pyobj(self, full_name: str) -> bool:
167207 )
168208
169209 def build_py_coverage (self ) -> None :
170- objects = self .env .domaindata ['py' ]['objects' ]
171- modules = self .env .domaindata ['py' ]['modules' ]
210+ seen_objects = self .env .domaindata ['py' ]['objects' ]
211+ seen_modules = self .env .domaindata ['py' ]['modules' ]
172212
173213 skip_undoc = self .config .coverage_skip_undoc_in_source
174214
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+
175248 for mod_name in modules :
176249 ignore = False
177250 for exp in self .mod_ignorexps :
@@ -211,7 +284,7 @@ def build_py_coverage(self) -> None:
211284 continue
212285
213286 if inspect .isfunction (obj ):
214- if full_name not in objects :
287+ if full_name not in seen_objects :
215288 for exp in self .fun_ignorexps :
216289 if exp .match (name ):
217290 break
@@ -227,7 +300,7 @@ def build_py_coverage(self) -> None:
227300 if exp .match (name ):
228301 break
229302 else :
230- if full_name not in objects :
303+ if full_name not in seen_objects :
231304 if skip_undoc and not obj .__doc__ :
232305 continue
233306 # not documented at all
@@ -255,7 +328,7 @@ def build_py_coverage(self) -> None:
255328 full_attr_name = f'{ full_name } .{ attr_name } '
256329 if self .ignore_pyobj (full_attr_name ):
257330 continue
258- if full_attr_name not in objects :
331+ if full_attr_name not in seen_objects :
259332 attrs .append (attr_name )
260333 undocumented_objects .add (full_attr_name )
261334 else :
@@ -385,6 +458,7 @@ def finish(self) -> None:
385458
386459def setup (app : Sphinx ) -> dict [str , Any ]:
387460 app .add_builder (CoverageBuilder )
461+ app .add_config_value ('coverage_modules' , [], False )
388462 app .add_config_value ('coverage_ignore_modules' , [], False )
389463 app .add_config_value ('coverage_ignore_functions' , [], False )
390464 app .add_config_value ('coverage_ignore_classes' , [], False )
0 commit comments