99import glob
1010import inspect
1111import pickle
12+ import pkgutil
1213import re
1314import sys
1415from importlib import import_module
2324from sphinx .util .inspect import safe_getattr
2425
2526if TYPE_CHECKING :
26- from collections .abc import Iterator
27+ from collections .abc import Iterable , Iterator , Sequence , Set
2728
2829 from sphinx .application import Sphinx
2930 from sphinx .util .typing import ExtensionMetadata
@@ -66,6 +67,93 @@ 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 : Iterable [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+ if any (exp .match (mod_name ) for exp in ignored_module_exps ):
81+ return set ()
82+
83+ # This can raise an exception, which must be handled by the caller.
84+ mod = import_module (mod_name )
85+ modules = {mod_name }
86+ if mod .__spec__ is None :
87+ return modules
88+
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__' :
92+ continue
93+
94+ if sub_mod_ispkg :
95+ modules |= _load_modules (f'{ mod_name } .{ sub_mod_name } ' , ignored_module_exps )
96+ else :
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 } ' )
100+
101+ return modules
102+
103+
104+ def _determine_py_coverage_modules (
105+ coverage_modules : Sequence [str ],
106+ seen_modules : Set [str ],
107+ ignored_module_exps : Iterable [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+ if not coverage_modules :
126+ return sorted (seen_modules )
127+
128+ modules : set [str ] = set ()
129+ for mod_name in coverage_modules :
130+ try :
131+ modules |= _load_modules (mod_name , ignored_module_exps )
132+ except ImportError as err :
133+ # TODO(stephenfin): Define a subtype for all logs in this module
134+ logger .warning (__ ('module %s could not be imported: %s' ), mod_name , err )
135+ py_undoc [mod_name ] = {'error' : err }
136+ continue
137+
138+ # if there are additional modules then we warn but continue scanning
139+ if additional_modules := seen_modules - modules :
140+ logger .warning (
141+ __ ('the following modules are documented but were not specified '
142+ 'in coverage_modules: %s' ),
143+ ', ' .join (additional_modules ),
144+ )
145+
146+ # likewise, if there are missing modules we warn but continue scanning
147+ if missing_modules := modules - seen_modules :
148+ logger .warning (
149+ __ ('the following modules are specified in coverage_modules '
150+ 'but were not documented' ),
151+ ', ' .join (missing_modules ),
152+ )
153+
154+ return sorted (modules )
155+
156+
69157class CoverageBuilder (Builder ):
70158 """
71159 Evaluates coverage of code in the documentation.
@@ -106,12 +194,12 @@ def get_outdated_docs(self) -> str:
106194
107195 def write (self , * ignored : Any ) -> None :
108196 self .py_undoc : dict [str , dict [str , Any ]] = {}
109- self .py_undocumented : dict [str , set [str ]] = {}
110- self .py_documented : dict [str , set [str ]] = {}
197+ self .py_undocumented : dict [str , Set [str ]] = {}
198+ self .py_documented : dict [str , Set [str ]] = {}
111199 self .build_py_coverage ()
112200 self .write_py_coverage ()
113201
114- self .c_undoc : dict [str , set [tuple [str , str ]]] = {}
202+ self .c_undoc : dict [str , Set [tuple [str , str ]]] = {}
115203 self .build_c_coverage ()
116204 self .write_c_coverage ()
117205
@@ -169,11 +257,14 @@ def ignore_pyobj(self, full_name: str) -> bool:
169257 )
170258
171259 def build_py_coverage (self ) -> None :
172- objects = self .env .domaindata ['py' ]['objects' ]
173- modules = self .env .domaindata ['py' ]['modules' ]
260+ seen_objects = frozenset ( self .env .domaindata ['py' ]['objects' ])
261+ seen_modules = frozenset ( self .env .domaindata ['py' ]['modules' ])
174262
175263 skip_undoc = self .config .coverage_skip_undoc_in_source
176264
265+ modules = _determine_py_coverage_modules (
266+ self .config .coverage_modules , seen_modules , self .mod_ignorexps , self .py_undoc ,
267+ )
177268 for mod_name in modules :
178269 ignore = False
179270 for exp in self .mod_ignorexps :
@@ -213,7 +304,7 @@ def build_py_coverage(self) -> None:
213304 continue
214305
215306 if inspect .isfunction (obj ):
216- if full_name not in objects :
307+ if full_name not in seen_objects :
217308 for exp in self .fun_ignorexps :
218309 if exp .match (name ):
219310 break
@@ -229,7 +320,7 @@ def build_py_coverage(self) -> None:
229320 if exp .match (name ):
230321 break
231322 else :
232- if full_name not in objects :
323+ if full_name not in seen_objects :
233324 if skip_undoc and not obj .__doc__ :
234325 continue
235326 # not documented at all
@@ -257,7 +348,7 @@ def build_py_coverage(self) -> None:
257348 full_attr_name = f'{ full_name } .{ attr_name } '
258349 if self .ignore_pyobj (full_attr_name ):
259350 continue
260- if full_attr_name not in objects :
351+ if full_attr_name not in seen_objects :
261352 attrs .append (attr_name )
262353 undocumented_objects .add (full_attr_name )
263354 else :
@@ -273,19 +364,17 @@ def build_py_coverage(self) -> None:
273364
274365 def _write_py_statistics (self , op : TextIO ) -> None :
275366 """Outputs the table of ``op``."""
276- all_modules = set (self .py_documented .keys ()).union (
277- set (self .py_undocumented .keys ()))
278- all_objects : set [str ] = set ()
279- all_documented_objects : set [str ] = set ()
367+ all_modules = frozenset (self .py_documented .keys () | self .py_undocumented .keys ())
368+ all_objects : Set [str ] = set ()
369+ all_documented_objects : Set [str ] = set ()
280370 for module in all_modules :
281- all_module_objects = self .py_documented [module ].union (self .py_undocumented [module ])
282- all_objects = all_objects .union (all_module_objects )
283- all_documented_objects = all_documented_objects .union (self .py_documented [module ])
371+ all_objects |= self .py_documented [module ] | self .py_undocumented [module ]
372+ all_documented_objects |= self .py_documented [module ]
284373
285374 # prepare tabular
286375 table = [['Module' , 'Coverage' , 'Undocumented' ]]
287- for module in all_modules :
288- module_objects = self .py_documented [module ]. union ( self .py_undocumented [module ])
376+ for module in sorted ( all_modules ) :
377+ module_objects = self .py_documented [module ] | self .py_undocumented [module ]
289378 if len (module_objects ):
290379 value = 100.0 * len (self .py_documented [module ]) / len (module_objects )
291380 else :
@@ -391,6 +480,7 @@ def finish(self) -> None:
391480
392481def setup (app : Sphinx ) -> ExtensionMetadata :
393482 app .add_builder (CoverageBuilder )
483+ app .add_config_value ('coverage_modules' , (), '' , types = {tuple , list })
394484 app .add_config_value ('coverage_ignore_modules' , [], '' )
395485 app .add_config_value ('coverage_ignore_functions' , [], '' )
396486 app .add_config_value ('coverage_ignore_classes' , [], '' )
0 commit comments