Skip to content

Commit 02942b0

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 1e0bc26 commit 02942b0

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[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+
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',
@@ -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

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

Comments
 (0)