Skip to content

Commit bb5c3c3

Browse files
committed
coverage: Specify modules to run coverage for
Currently, the coverage builder lets you check for partially documented modules, but there is no mechanism to identify totally undocumented modules. Resolve this by introducing a new 'coverage_modules' config option. This is a list of modules that should be documented somewhere within the documentation tree. Any modules that are specified in the configuration value but are not documented anywhere will result in a warning. Likewise, any modules that are not in the config option but are documented somewhere will result in a warning. Signed-off-by: Stephen Finucane <[email protected]>
1 parent 45675b5 commit bb5c3c3

File tree

2 files changed

+127
-10
lines changed

2 files changed

+127
-10
lines changed

doc/usage/extensions/coverage.rst

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,58 @@
66

77
This extension features one additional builder, the :class:`CoverageBuilder`.
88

9+
.. todo:: Write this section.
10+
11+
.. note::
12+
13+
The :doc:`sphinx-apidoc </man/sphinx-apidoc>` command can be used to
14+
automatically generate API documentation for all code in a project, avoiding
15+
the need to manually author these documents and keep them up-to-date.
16+
17+
.. warning::
18+
19+
:mod:`~sphinx.ext.coverage` **imports** the modules to be documented. If any
20+
modules have side effects on import, these will be executed by the coverage
21+
builder when ``sphinx-build`` is run.
22+
23+
If you document scripts (as opposed to library modules), make sure their main
24+
routine is protected by a ``if __name__ == '__main__'`` condition.
25+
26+
.. note::
27+
28+
For Sphinx (actually, the Python interpreter that executes Sphinx) to find
29+
your module, it must be importable. That means that the module or the
30+
package must be in one of the directories on :data:`sys.path` -- adapt your
31+
:data:`sys.path` in the configuration file accordingly.
32+
33+
To use this builder, activate the coverage extension in your configuration file
34+
and give ``-b coverage`` on the command line.
35+
36+
37+
Builder
38+
-------
39+
940
.. class:: CoverageBuilder
1041

11-
To use this builder, activate the coverage extension in your configuration
12-
file and give ``-b coverage`` on the command line.
1342

14-
.. todo:: Write this section.
43+
Configuration
44+
-------------
45+
46+
Several configuration values can be used to specify what the builder should
47+
check:
48+
49+
.. confval:: coverage_modules
50+
51+
List of Python packages or modules to test coverage for. When this is
52+
provided, Sphinx will introspect each package or module provided in this
53+
list as well as all sub-packages and sub-modules found in each. When this is
54+
not provided, Sphinx will only provide coverage for Python packages and
55+
modules that it is aware of: that is, any modules documented using the
56+
:rst:dir:`py:module` directive provided in the :ref:`Python domain
57+
<python-domain>` or the :rst:dir:`automodule` directive provided by the
58+
:mod:`~sphinx.ext.autodoc` extension.
1559

16-
Several configuration values can be used to specify what the builder
17-
should check:
60+
.. versionadded: 7.2
1861
1962
.. confval:: coverage_ignore_modules
2063

sphinx/ext/coverage.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import glob
1010
import inspect
1111
import pickle
12+
import pkgutil
1213
import re
1314
import sys
1415
from 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+
68108
class 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

386459
def 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

Comments
 (0)