Skip to content

Commit 6b37a6b

Browse files
authored
Allow explicitly specifying modules in the coverage builder (#11592)
Currently there is no mechanism to identify totally undocumented modules in the coverage builder, unlike with partially documented 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 3d118ce commit 6b37a6b

File tree

10 files changed

+206
-40
lines changed

10 files changed

+206
-40
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ Features added
7171
* #12523: Added configuration option, :confval:`math_numsep`, to define the
7272
separator for math numbering.
7373
Patch by Thomas Fanning
74+
* #11592: Add :confval:`coverage_modules` to the coverage builder
75+
to allow explicitly specifying which modules should be documented.
76+
Patch by Stephen Finucane.
7477

7578
Bugs fixed
7679
----------

doc/usage/extensions/coverage.rst

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,64 @@
66

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

9-
.. class:: CoverageBuilder
9+
.. todo:: Write this section.
1010

11-
To use this builder, activate the coverage extension in your configuration
12-
file and give ``-M coverage`` on the command line.
11+
.. note::
1312

14-
.. todo:: Write this section.
13+
The :doc:`sphinx-apidoc </man/sphinx-apidoc>` command can be used to
14+
automatically generate API documentation for all code in a project,
15+
avoiding 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.
20+
If any modules have side effects on import,
21+
these will be executed by the coverage builder when ``sphinx-build`` is run.
22+
23+
If you document scripts (as opposed to library modules),
24+
make sure their main routine is protected by a
25+
``if __name__ == '__main__'`` condition.
26+
27+
.. note::
28+
29+
For Sphinx (actually, the Python interpreter that executes Sphinx)
30+
to find your module, it must be importable.
31+
That means that the module or the package must be in
32+
one of the directories on :data:`sys.path` -- adapt your :data:`sys.path`
33+
in the configuration file accordingly.
34+
35+
To use this builder, activate the coverage extension in your configuration file
36+
and run ``sphinx-build -M coverage`` on the command line.
37+
38+
39+
Builder
40+
-------
41+
42+
.. py:class:: CoverageBuilder
43+
44+
45+
Configuration
46+
-------------
47+
48+
Several configuration values can be used to specify
49+
what the builder should check:
50+
51+
.. confval:: coverage_modules
52+
:type: ``list[str]``
53+
:default: ``[]``
54+
55+
List of Python packages or modules to test coverage for.
56+
When this is provided, Sphinx will introspect each package
57+
or module provided in this list as well
58+
as all sub-packages and sub-modules found in each.
59+
When this is not provided, Sphinx will only provide coverage
60+
for Python packages and modules that it is aware of:
61+
that is, any modules documented using the :rst:dir:`py:module` directive
62+
provided in the :doc:`Python domain </usage/domains/python>`
63+
or the :rst:dir:`automodule` directive provided by the
64+
:mod:`~sphinx.ext.autodoc` extension.
1565

16-
Several configuration values can be used to specify what the builder
17-
should check:
66+
.. versionadded:: 7.4
1867

1968
.. confval:: coverage_ignore_modules
2069

sphinx/ext/coverage.py

Lines changed: 108 additions & 18 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
@@ -23,7 +24,7 @@
2324
from sphinx.util.inspect import safe_getattr
2425

2526
if 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+
69157
class 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

392481
def 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', [], '')

tests/roots/test-ext-coverage/conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage']
77

8+
coverage_modules = [
9+
'grog',
10+
]
811
coverage_ignore_pyobjects = [
9-
r'^coverage_ignored(\..*)?$',
12+
r'^grog\.coverage_ignored(\..*)?$',
1013
r'\.Ignored$',
1114
r'\.Documented\.ignored\d$',
1215
]

tests/roots/test-ext-coverage/grog/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""This module is intentionally not documented."""
2+
3+
class Missing:
4+
"""An undocumented class."""
5+
6+
def missing_a(self):
7+
"""An undocumented method."""
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
.. automodule:: coverage_ignored
1+
.. automodule:: grog.coverage_ignored
22
:members:
33

44

5-
.. automodule:: coverage_not_ignored
5+
.. automodule:: grog.coverage_not_ignored
66
:members:

tests/test_extensions/test_ext_coverage.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ def test_build(app, status, warning):
1010
app.build(force_all=True)
1111

1212
py_undoc = (app.outdir / 'python.txt').read_text(encoding='utf8')
13-
assert py_undoc.startswith('Undocumented Python objects\n'
14-
'===========================\n')
13+
assert py_undoc.startswith(
14+
'Undocumented Python objects\n'
15+
'===========================\n',
16+
)
1517
assert 'autodoc_target\n--------------\n' in py_undoc
1618
assert ' * Class -- missing methods:\n' in py_undoc
1719
assert ' * raises\n' in py_undoc
@@ -23,8 +25,10 @@ def test_build(app, status, warning):
2325
assert "undocumented py" not in status.getvalue()
2426

2527
c_undoc = (app.outdir / 'c.txt').read_text(encoding='utf8')
26-
assert c_undoc.startswith('Undocumented C API elements\n'
27-
'===========================\n')
28+
assert c_undoc.startswith(
29+
'Undocumented C API elements\n'
30+
'===========================\n',
31+
)
2832
assert 'api.h' in c_undoc
2933
assert ' * Py_SphinxTest' in c_undoc
3034

@@ -54,16 +58,26 @@ def test_coverage_ignore_pyobjects(app, status, warning):
5458
Statistics
5559
----------
5660
57-
+----------------------+----------+--------------+
58-
| Module | Coverage | Undocumented |
59-
+======================+==========+==============+
60-
| coverage_not_ignored | 0.00% | 2 |
61-
+----------------------+----------+--------------+
62-
| TOTAL | 0.00% | 2 |
63-
+----------------------+----------+--------------+
61+
+---------------------------+----------+--------------+
62+
| Module | Coverage | Undocumented |
63+
+===========================+==========+==============+
64+
| grog | 100.00% | 0 |
65+
+---------------------------+----------+--------------+
66+
| grog.coverage_missing | 100.00% | 0 |
67+
+---------------------------+----------+--------------+
68+
| grog.coverage_not_ignored | 0.00% | 2 |
69+
+---------------------------+----------+--------------+
70+
| TOTAL | 0.00% | 2 |
71+
+---------------------------+----------+--------------+
72+
73+
grog.coverage_missing
74+
---------------------
6475
65-
coverage_not_ignored
66-
--------------------
76+
Classes:
77+
* Missing
78+
79+
grog.coverage_not_ignored
80+
-------------------------
6781
6882
Classes:
6983
* Documented -- missing methods:

0 commit comments

Comments
 (0)