diff --git a/CHANGES.rst b/CHANGES.rst index fcdcf2df9af..1f178f5c200 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -71,6 +71,9 @@ Features added * #12523: Added configuration option, :confval:`math_numsep`, to define the separator for math numbering. Patch by Thomas Fanning +* #11592: Add :confval:`coverage_modules` to the coverage builder + to allow explicitly specifying which modules should be documented. + Patch by Stephen Finucane. Bugs fixed ---------- diff --git a/doc/usage/extensions/coverage.rst b/doc/usage/extensions/coverage.rst index b9c493b5f3b..75ffc0f59ed 100644 --- a/doc/usage/extensions/coverage.rst +++ b/doc/usage/extensions/coverage.rst @@ -6,15 +6,64 @@ This extension features one additional builder, the :class:`CoverageBuilder`. -.. class:: CoverageBuilder +.. todo:: Write this section. - To use this builder, activate the coverage extension in your configuration - file and give ``-M coverage`` on the command line. +.. note:: -.. todo:: Write this section. + The :doc:`sphinx-apidoc ` command can be used to + automatically generate API documentation for all code in a project, + avoiding the need to manually author these documents and keep them up-to-date. + +.. warning:: + + :mod:`~sphinx.ext.coverage` **imports** the modules to be documented. + If any modules have side effects on import, + these will be executed by the coverage builder when ``sphinx-build`` is run. + + If you document scripts (as opposed to library modules), + make sure their main routine is protected by a + ``if __name__ == '__main__'`` condition. + +.. note:: + + For Sphinx (actually, the Python interpreter that executes Sphinx) + to find your module, it must be importable. + That means that the module or the package must be in + one of the directories on :data:`sys.path` -- adapt your :data:`sys.path` + in the configuration file accordingly. + +To use this builder, activate the coverage extension in your configuration file +and run ``sphinx-build -M coverage`` on the command line. + + +Builder +------- + +.. py:class:: CoverageBuilder + + +Configuration +------------- + +Several configuration values can be used to specify +what the builder should check: + +.. confval:: coverage_modules + :type: ``list[str]`` + :default: ``[]`` + + List of Python packages or modules to test coverage for. + When this is provided, Sphinx will introspect each package + or module provided in this list as well + as all sub-packages and sub-modules found in each. + When this is not provided, Sphinx will only provide coverage + for Python packages and modules that it is aware of: + that is, any modules documented using the :rst:dir:`py:module` directive + provided in the :doc:`Python domain ` + or the :rst:dir:`automodule` directive provided by the + :mod:`~sphinx.ext.autodoc` extension. -Several configuration values can be used to specify what the builder -should check: + .. versionadded:: 7.4 .. confval:: coverage_ignore_modules diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index cfe093623c1..f7ce3beaa65 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -9,6 +9,7 @@ import glob import inspect import pickle +import pkgutil import re import sys from importlib import import_module @@ -23,7 +24,7 @@ from sphinx.util.inspect import safe_getattr if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator, Sequence, Set from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -66,6 +67,93 @@ def _add_row(col_widths: list[int], columns: list[str], separator: str) -> Itera yield _add_line(col_widths, separator) +def _load_modules(mod_name: str, ignored_module_exps: Iterable[re.Pattern[str]]) -> Set[str]: + """Recursively load all submodules. + + :param mod_name: The name of a module to load submodules for. + :param ignored_module_exps: A list of regexes for modules to ignore. + :returns: A set of modules names including the provided module name, + ``mod_name`` + :raises ImportError: If the module indicated by ``mod_name`` could not be + loaded. + """ + if any(exp.match(mod_name) for exp in ignored_module_exps): + return set() + + # This can raise an exception, which must be handled by the caller. + mod = import_module(mod_name) + modules = {mod_name} + if mod.__spec__ is None: + return modules + + search_locations = mod.__spec__.submodule_search_locations + for (_, sub_mod_name, sub_mod_ispkg) in pkgutil.iter_modules(search_locations): + if sub_mod_name == '__main__': + continue + + if sub_mod_ispkg: + modules |= _load_modules(f'{mod_name}.{sub_mod_name}', ignored_module_exps) + else: + if any(exp.match(sub_mod_name) for exp in ignored_module_exps): + continue + modules.add(f'{mod_name}.{sub_mod_name}') + + return modules + + +def _determine_py_coverage_modules( + coverage_modules: Sequence[str], + seen_modules: Set[str], + ignored_module_exps: Iterable[re.Pattern[str]], + py_undoc: dict[str, dict[str, Any]], +) -> list[str]: + """Return a sorted list of modules to check for coverage. + + Figure out which of the two operating modes to use: + + - If 'coverage_modules' is not specified, we check coverage for all modules + seen in the documentation tree. Any objects found in these modules that are + not documented will be noted. This will therefore only identify missing + objects, but it requires no additional configuration. + + - If 'coverage_modules' is specified, we check coverage for all modules + specified in this configuration value. Any objects found in these modules + that are not documented will be noted. In addition, any objects from other + modules that are documented will be noted. This will therefore identify both + missing modules and missing objects, but it requires manual configuration. + """ + if not coverage_modules: + return sorted(seen_modules) + + modules: set[str] = set() + for mod_name in coverage_modules: + try: + modules |= _load_modules(mod_name, ignored_module_exps) + except ImportError as err: + # TODO(stephenfin): Define a subtype for all logs in this module + logger.warning(__('module %s could not be imported: %s'), mod_name, err) + py_undoc[mod_name] = {'error': err} + continue + + # if there are additional modules then we warn but continue scanning + if additional_modules := seen_modules - modules: + logger.warning( + __('the following modules are documented but were not specified ' + 'in coverage_modules: %s'), + ', '.join(additional_modules), + ) + + # likewise, if there are missing modules we warn but continue scanning + if missing_modules := modules - seen_modules: + logger.warning( + __('the following modules are specified in coverage_modules ' + 'but were not documented'), + ', '.join(missing_modules), + ) + + return sorted(modules) + + class CoverageBuilder(Builder): """ Evaluates coverage of code in the documentation. @@ -106,12 +194,12 @@ def get_outdated_docs(self) -> str: def write(self, *ignored: Any) -> None: self.py_undoc: dict[str, dict[str, Any]] = {} - self.py_undocumented: dict[str, set[str]] = {} - self.py_documented: dict[str, set[str]] = {} + self.py_undocumented: dict[str, Set[str]] = {} + self.py_documented: dict[str, Set[str]] = {} self.build_py_coverage() self.write_py_coverage() - self.c_undoc: dict[str, set[tuple[str, str]]] = {} + self.c_undoc: dict[str, Set[tuple[str, str]]] = {} self.build_c_coverage() self.write_c_coverage() @@ -169,11 +257,14 @@ def ignore_pyobj(self, full_name: str) -> bool: ) def build_py_coverage(self) -> None: - objects = self.env.domaindata['py']['objects'] - modules = self.env.domaindata['py']['modules'] + seen_objects = frozenset(self.env.domaindata['py']['objects']) + seen_modules = frozenset(self.env.domaindata['py']['modules']) skip_undoc = self.config.coverage_skip_undoc_in_source + modules = _determine_py_coverage_modules( + self.config.coverage_modules, seen_modules, self.mod_ignorexps, self.py_undoc, + ) for mod_name in modules: ignore = False for exp in self.mod_ignorexps: @@ -213,7 +304,7 @@ def build_py_coverage(self) -> None: continue if inspect.isfunction(obj): - if full_name not in objects: + if full_name not in seen_objects: for exp in self.fun_ignorexps: if exp.match(name): break @@ -229,7 +320,7 @@ def build_py_coverage(self) -> None: if exp.match(name): break else: - if full_name not in objects: + if full_name not in seen_objects: if skip_undoc and not obj.__doc__: continue # not documented at all @@ -257,7 +348,7 @@ def build_py_coverage(self) -> None: full_attr_name = f'{full_name}.{attr_name}' if self.ignore_pyobj(full_attr_name): continue - if full_attr_name not in objects: + if full_attr_name not in seen_objects: attrs.append(attr_name) undocumented_objects.add(full_attr_name) else: @@ -273,19 +364,17 @@ def build_py_coverage(self) -> None: def _write_py_statistics(self, op: TextIO) -> None: """Outputs the table of ``op``.""" - all_modules = set(self.py_documented.keys()).union( - set(self.py_undocumented.keys())) - all_objects: set[str] = set() - all_documented_objects: set[str] = set() + all_modules = frozenset(self.py_documented.keys() | self.py_undocumented.keys()) + all_objects: Set[str] = set() + all_documented_objects: Set[str] = set() for module in all_modules: - all_module_objects = self.py_documented[module].union(self.py_undocumented[module]) - all_objects = all_objects.union(all_module_objects) - all_documented_objects = all_documented_objects.union(self.py_documented[module]) + all_objects |= self.py_documented[module] | self.py_undocumented[module] + all_documented_objects |= self.py_documented[module] # prepare tabular table = [['Module', 'Coverage', 'Undocumented']] - for module in all_modules: - module_objects = self.py_documented[module].union(self.py_undocumented[module]) + for module in sorted(all_modules): + module_objects = self.py_documented[module] | self.py_undocumented[module] if len(module_objects): value = 100.0 * len(self.py_documented[module]) / len(module_objects) else: @@ -391,6 +480,7 @@ def finish(self) -> None: def setup(app: Sphinx) -> ExtensionMetadata: app.add_builder(CoverageBuilder) + app.add_config_value('coverage_modules', (), '', types={tuple, list}) app.add_config_value('coverage_ignore_modules', [], '') app.add_config_value('coverage_ignore_functions', [], '') app.add_config_value('coverage_ignore_classes', [], '') diff --git a/tests/roots/test-ext-coverage/conf.py b/tests/roots/test-ext-coverage/conf.py index d3ec6e87f34..70fd03e91b2 100644 --- a/tests/roots/test-ext-coverage/conf.py +++ b/tests/roots/test-ext-coverage/conf.py @@ -5,8 +5,11 @@ extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] +coverage_modules = [ + 'grog', +] coverage_ignore_pyobjects = [ - r'^coverage_ignored(\..*)?$', + r'^grog\.coverage_ignored(\..*)?$', r'\.Ignored$', r'\.Documented\.ignored\d$', ] diff --git a/tests/roots/test-ext-coverage/grog/__init__.py b/tests/roots/test-ext-coverage/grog/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/roots/test-ext-coverage/coverage_ignored.py b/tests/roots/test-ext-coverage/grog/coverage_ignored.py similarity index 100% rename from tests/roots/test-ext-coverage/coverage_ignored.py rename to tests/roots/test-ext-coverage/grog/coverage_ignored.py diff --git a/tests/roots/test-ext-coverage/grog/coverage_missing.py b/tests/roots/test-ext-coverage/grog/coverage_missing.py new file mode 100644 index 00000000000..2fe44338caa --- /dev/null +++ b/tests/roots/test-ext-coverage/grog/coverage_missing.py @@ -0,0 +1,7 @@ +"""This module is intentionally not documented.""" + +class Missing: + """An undocumented class.""" + + def missing_a(self): + """An undocumented method.""" diff --git a/tests/roots/test-ext-coverage/coverage_not_ignored.py b/tests/roots/test-ext-coverage/grog/coverage_not_ignored.py similarity index 100% rename from tests/roots/test-ext-coverage/coverage_not_ignored.py rename to tests/roots/test-ext-coverage/grog/coverage_not_ignored.py diff --git a/tests/roots/test-ext-coverage/index.rst b/tests/roots/test-ext-coverage/index.rst index b8468987ee2..85dccf9b1eb 100644 --- a/tests/roots/test-ext-coverage/index.rst +++ b/tests/roots/test-ext-coverage/index.rst @@ -1,6 +1,6 @@ -.. automodule:: coverage_ignored +.. automodule:: grog.coverage_ignored :members: -.. automodule:: coverage_not_ignored +.. automodule:: grog.coverage_not_ignored :members: diff --git a/tests/test_extensions/test_ext_coverage.py b/tests/test_extensions/test_ext_coverage.py index c9e9ba93a6d..ed7b5adac37 100644 --- a/tests/test_extensions/test_ext_coverage.py +++ b/tests/test_extensions/test_ext_coverage.py @@ -10,8 +10,10 @@ def test_build(app, status, warning): app.build(force_all=True) py_undoc = (app.outdir / 'python.txt').read_text(encoding='utf8') - assert py_undoc.startswith('Undocumented Python objects\n' - '===========================\n') + assert py_undoc.startswith( + 'Undocumented Python objects\n' + '===========================\n', + ) assert 'autodoc_target\n--------------\n' in py_undoc assert ' * Class -- missing methods:\n' in py_undoc assert ' * raises\n' in py_undoc @@ -23,8 +25,10 @@ def test_build(app, status, warning): assert "undocumented py" not in status.getvalue() c_undoc = (app.outdir / 'c.txt').read_text(encoding='utf8') - assert c_undoc.startswith('Undocumented C API elements\n' - '===========================\n') + assert c_undoc.startswith( + 'Undocumented C API elements\n' + '===========================\n', + ) assert 'api.h' in c_undoc assert ' * Py_SphinxTest' in c_undoc @@ -54,16 +58,26 @@ def test_coverage_ignore_pyobjects(app, status, warning): Statistics ---------- -+----------------------+----------+--------------+ -| Module | Coverage | Undocumented | -+======================+==========+==============+ -| coverage_not_ignored | 0.00% | 2 | -+----------------------+----------+--------------+ -| TOTAL | 0.00% | 2 | -+----------------------+----------+--------------+ ++---------------------------+----------+--------------+ +| Module | Coverage | Undocumented | ++===========================+==========+==============+ +| grog | 100.00% | 0 | ++---------------------------+----------+--------------+ +| grog.coverage_missing | 100.00% | 0 | ++---------------------------+----------+--------------+ +| grog.coverage_not_ignored | 0.00% | 2 | ++---------------------------+----------+--------------+ +| TOTAL | 0.00% | 2 | ++---------------------------+----------+--------------+ + +grog.coverage_missing +--------------------- -coverage_not_ignored --------------------- +Classes: + * Missing + +grog.coverage_not_ignored +------------------------- Classes: * Documented -- missing methods: