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 [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+ # 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' ,
@@ -167,11 +208,43 @@ def ignore_pyobj(self, full_name: str) -> bool:
167208 )
168209
169210 def build_py_coverage (self ) -> None :
170- objects = self .env .domaindata ['py' ]['objects' ]
171- modules = self .env .domaindata ['py' ]['modules' ]
211+ seen_objects = self .env .domaindata ['py' ]['objects' ]
212+ seen_modules = self .env .domaindata ['py' ]['modules' ]
172213
173214 skip_undoc = self .config .coverage_skip_undoc_in_source
174215
216+ # Figure out which of the two operating modes to use:
217+ #
218+ # - If 'coverage_modules' is not specified, we check coverage for all modules
219+ # seen in the documentation tree. Any objects found in these modules that are
220+ # not documented will be noted. This will therefore only identify missing
221+ # objects but it requires no additional configuration.
222+ # - If 'coverage_modules' is specified, we check coverage for all modules
223+ # specified in this configuration value. Any objects found in these modules
224+ # that are not documented will be noted. In addition, any objects from other
225+ # modules that are documented will be noted. This will therefore identify both
226+ # missing modules and missing objects but it requires manual configuration.
227+ if not self .modules :
228+ modules = set (seen_modules )
229+ else :
230+ modules = set ()
231+ for mod_name in self .modules :
232+ try :
233+ modules |= load_modules (mod_name , self .mod_ignorexps )
234+ except ImportError as err :
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