99import glob
1010import inspect
1111import pickle
12+ import pkgutil
1213import re
1314import sys
1415from importlib import import_module
@@ -65,6 +66,45 @@ 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 [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 ()
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+ # TODO: Do we want to skip private modules (note: different from private
96+ # objects)?
97+ # if sub_mod_info.name.startswith('_'):
98+ # continue
99+
100+ if sub_mod_info .ispkg :
101+ modules |= load_modules (f'{ mod_name } .{ sub_mod_info .name } ' , ignored_module_exps )
102+ else :
103+ modules .add (f'{ mod_name } .{ sub_mod_info .name } ' )
104+
105+ return modules
106+
107+
68108class CoverageBuilder (Builder ):
69109 """
70110 Evaluates coverage of code in the documentation.
@@ -90,6 +130,7 @@ def init(self) -> None:
90130 for (name , exps ) in self .config .coverage_ignore_c_items .items ():
91131 self .c_ignorexps [name ] = compile_regex_list ('coverage_ignore_c_items' ,
92132 exps )
133+ self .modules = self .config .coverage_modules
93134 self .mod_ignorexps = compile_regex_list ('coverage_ignore_modules' ,
94135 self .config .coverage_ignore_modules )
95136 self .cls_ignorexps = compile_regex_list ('coverage_ignore_classes' ,
@@ -162,11 +203,43 @@ def ignore_pyobj(self, full_name: str) -> bool:
162203 )
163204
164205 def build_py_coverage (self ) -> None :
165- objects = self .env .domaindata ['py' ]['objects' ]
166- modules = self .env .domaindata ['py' ]['modules' ]
206+ seen_objects = self .env .domaindata ['py' ]['objects' ]
207+ seen_modules = self .env .domaindata ['py' ]['modules' ]
167208
168209 skip_undoc = self .config .coverage_skip_undoc_in_source
169210
211+ # Figure out which of the two operating modes to use:
212+ #
213+ # - If 'coverage_modules' is not specified, we check coverage for all modules
214+ # seen in the documentation tree. Any objects found in these modules that are
215+ # not documented will be noted. This will therefore only identify missing
216+ # objects but it requires no additional configuration.
217+ # - If 'coverage_modules' is specified, we check coverage for all modules
218+ # specified in this configuration value. Any objects found in these modules
219+ # that are not documented will be noted. In addition, any objects from other
220+ # modules that are documented will be noted. This will therefore identify both
221+ # missing modules and missing objects but it requires manual configuration.
222+ if not self .modules :
223+ modules = set (seen_modules )
224+ else :
225+ modules = set ()
226+ for mod_name in self .modules :
227+ try :
228+ modules |= load_modules (mod_name , self .mod_ignorexps )
229+ except ImportError as err :
230+ logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
231+ self .py_undoc [mod_name ] = {'error' : err }
232+ continue
233+
234+ # if there are additional modules then we warn (but still scan)
235+ additional_modules = set (seen_modules ) - modules
236+ if additional_modules :
237+ logger .warning (
238+ __ ('the following modules are documented but were not specified '
239+ 'in coverage_modules: %s' ),
240+ ', ' .join (additional_modules ),
241+ )
242+
170243 for mod_name in modules :
171244 ignore = False
172245 for exp in self .mod_ignorexps :
@@ -206,7 +279,7 @@ def build_py_coverage(self) -> None:
206279 continue
207280
208281 if inspect .isfunction (obj ):
209- if full_name not in objects :
282+ if full_name not in seen_objects :
210283 for exp in self .fun_ignorexps :
211284 if exp .match (name ):
212285 break
@@ -222,7 +295,7 @@ def build_py_coverage(self) -> None:
222295 if exp .match (name ):
223296 break
224297 else :
225- if full_name not in objects :
298+ if full_name not in seen_objects :
226299 if skip_undoc and not obj .__doc__ :
227300 continue
228301 # not documented at all
@@ -250,7 +323,7 @@ def build_py_coverage(self) -> None:
250323 full_attr_name = f'{ full_name } .{ attr_name } '
251324 if self .ignore_pyobj (full_attr_name ):
252325 continue
253- if full_attr_name not in objects :
326+ if full_attr_name not in seen_objects :
254327 attrs .append (attr_name )
255328 undocumented_objects .add (full_attr_name )
256329 else :
@@ -380,6 +453,7 @@ def finish(self) -> None:
380453
381454def setup (app : Sphinx ) -> dict [str , Any ]:
382455 app .add_builder (CoverageBuilder )
456+ app .add_config_value ('coverage_modules' , [], False )
383457 app .add_config_value ('coverage_ignore_modules' , [], False )
384458 app .add_config_value ('coverage_ignore_functions' , [], False )
385459 app .add_config_value ('coverage_ignore_classes' , [], False )
0 commit comments