Skip to content

Commit 434996f

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 b23ac4f commit 434996f

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 ``-M 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 ``-M 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 :doc:`Python domain
57+
</usage/domains/python>` or the :rst:dir:`automodule` directive provided by
58+
the :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
@@ -66,6 +67,44 @@ 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: list[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+
modules: set[str] = set()
81+
82+
for exp in ignored_module_exps:
83+
if exp.match(mod_name):
84+
return modules
85+
86+
# this can raise an exception but it's the responsibility of the caller to
87+
# handle this
88+
mod = import_module(mod_name)
89+
90+
modules.add(mod_name)
91+
92+
for sub_mod_info in pkgutil.iter_modules(mod.__path__):
93+
if sub_mod_info.name == '__main__':
94+
continue
95+
96+
if sub_mod_info.ispkg:
97+
modules |= _load_modules(f'{mod_name}.{sub_mod_info.name}', ignored_module_exps)
98+
else:
99+
for exp in ignored_module_exps:
100+
if exp.match(sub_mod_info.name):
101+
continue
102+
103+
modules.add(f'{mod_name}.{sub_mod_info.name}')
104+
105+
return modules
106+
107+
69108
class CoverageBuilder(Builder):
70109
"""
71110
Evaluates coverage of code in the documentation.
@@ -92,6 +131,7 @@ def init(self) -> None:
92131
for (name, exps) in self.config.coverage_ignore_c_items.items():
93132
self.c_ignorexps[name] = compile_regex_list('coverage_ignore_c_items',
94133
exps)
134+
self.module_names = self.config.coverage_modules
95135
self.mod_ignorexps = compile_regex_list('coverage_ignore_modules',
96136
self.config.coverage_ignore_modules)
97137
self.cls_ignorexps = compile_regex_list('coverage_ignore_classes',
@@ -169,11 +209,44 @@ def ignore_pyobj(self, full_name: str) -> bool:
169209
)
170210

171211
def build_py_coverage(self) -> None:
172-
objects = self.env.domaindata['py']['objects']
173-
modules = self.env.domaindata['py']['modules']
212+
seen_objects = self.env.domaindata['py']['objects']
213+
seen_modules = self.env.domaindata['py']['modules']
174214

175215
skip_undoc = self.config.coverage_skip_undoc_in_source
176216

217+
# Figure out which of the two operating modes to use:
218+
#
219+
# - If 'coverage_modules' is not specified, we check coverage for all modules
220+
# seen in the documentation tree. Any objects found in these modules that are
221+
# not documented will be noted. This will therefore only identify missing
222+
# objects but it requires no additional configuration.
223+
# - If 'coverage_modules' is specified, we check coverage for all modules
224+
# specified in this configuration value. Any objects found in these modules
225+
# that are not documented will be noted. In addition, any objects from other
226+
# modules that are documented will be noted. This will therefore identify both
227+
# missing modules and missing objects but it requires manual configuration.
228+
if not self.module_names:
229+
modules = set(seen_modules)
230+
else:
231+
modules = set()
232+
for mod_name in self.module_names:
233+
try:
234+
modules |= _load_modules(mod_name, self.mod_ignorexps)
235+
except ImportError as err:
236+
# TODO(stephenfin): Define a subtype for all logs in this module
237+
logger.warning(__('module %s could not be imported: %s'), mod_name, err)
238+
self.py_undoc[mod_name] = {'error': err}
239+
continue
240+
241+
# if there are additional modules then we warn (but still scan)
242+
additional_modules = set(seen_modules) - modules
243+
if additional_modules:
244+
logger.warning(
245+
__('the following modules are documented but were not specified '
246+
'in coverage_modules: %s'),
247+
', '.join(additional_modules),
248+
)
249+
177250
for mod_name in modules:
178251
ignore = False
179252
for exp in self.mod_ignorexps:
@@ -213,7 +286,7 @@ def build_py_coverage(self) -> None:
213286
continue
214287

215288
if inspect.isfunction(obj):
216-
if full_name not in objects:
289+
if full_name not in seen_objects:
217290
for exp in self.fun_ignorexps:
218291
if exp.match(name):
219292
break
@@ -229,7 +302,7 @@ def build_py_coverage(self) -> None:
229302
if exp.match(name):
230303
break
231304
else:
232-
if full_name not in objects:
305+
if full_name not in seen_objects:
233306
if skip_undoc and not obj.__doc__:
234307
continue
235308
# not documented at all
@@ -257,7 +330,7 @@ def build_py_coverage(self) -> None:
257330
full_attr_name = f'{full_name}.{attr_name}'
258331
if self.ignore_pyobj(full_attr_name):
259332
continue
260-
if full_attr_name not in objects:
333+
if full_attr_name not in seen_objects:
261334
attrs.append(attr_name)
262335
undocumented_objects.add(full_attr_name)
263336
else:
@@ -391,6 +464,7 @@ def finish(self) -> None:
391464

392465
def setup(app: Sphinx) -> ExtensionMetadata:
393466
app.add_builder(CoverageBuilder)
467+
app.add_config_value('coverage_modules', [], '')
394468
app.add_config_value('coverage_ignore_modules', [], '')
395469
app.add_config_value('coverage_ignore_functions', [], '')
396470
app.add_config_value('coverage_ignore_classes', [], '')

0 commit comments

Comments
 (0)