From 434996f5d562b5815870005c6312e3057d451f11 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 12 Aug 2023 20:04:05 +0100 Subject: [PATCH 1/8] 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 --- doc/usage/extensions/coverage.rst | 53 +++++++++++++++++-- sphinx/ext/coverage.py | 84 +++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 10 deletions(-) diff --git a/doc/usage/extensions/coverage.rst b/doc/usage/extensions/coverage.rst index b9c493b5f3b..3e24579d88b 100644 --- a/doc/usage/extensions/coverage.rst +++ b/doc/usage/extensions/coverage.rst @@ -6,15 +6,58 @@ This extension features one additional builder, the :class:`CoverageBuilder`. +.. todo:: Write this section. + +.. note:: + + 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 give ``-M coverage`` on the command line. + + +Builder +------- + .. class:: CoverageBuilder - To use this builder, activate the coverage extension in your configuration - file and give ``-M coverage`` on the command line. -.. todo:: Write this section. +Configuration +------------- + +Several configuration values can be used to specify what the builder should +check: + +.. confval:: coverage_modules + + 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.2 .. confval:: coverage_ignore_modules diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index cfe093623c1..4fb1ce1dfad 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 @@ -66,6 +67,44 @@ 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: list[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. + """ + modules: set[str] = set() + + for exp in ignored_module_exps: + if exp.match(mod_name): + return modules + + # this can raise an exception but it's the responsibility of the caller to + # handle this + mod = import_module(mod_name) + + modules.add(mod_name) + + for sub_mod_info in pkgutil.iter_modules(mod.__path__): + if sub_mod_info.name == '__main__': + continue + + if sub_mod_info.ispkg: + modules |= _load_modules(f'{mod_name}.{sub_mod_info.name}', ignored_module_exps) + else: + for exp in ignored_module_exps: + if exp.match(sub_mod_info.name): + continue + + modules.add(f'{mod_name}.{sub_mod_info.name}') + + return modules + + class CoverageBuilder(Builder): """ Evaluates coverage of code in the documentation. @@ -92,6 +131,7 @@ def init(self) -> None: for (name, exps) in self.config.coverage_ignore_c_items.items(): self.c_ignorexps[name] = compile_regex_list('coverage_ignore_c_items', exps) + self.module_names = self.config.coverage_modules self.mod_ignorexps = compile_regex_list('coverage_ignore_modules', self.config.coverage_ignore_modules) self.cls_ignorexps = compile_regex_list('coverage_ignore_classes', @@ -169,11 +209,44 @@ 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 = self.env.domaindata['py']['objects'] + seen_modules = self.env.domaindata['py']['modules'] skip_undoc = self.config.coverage_skip_undoc_in_source + # 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 self.module_names: + modules = set(seen_modules) + else: + modules = set() + for mod_name in self.module_names: + try: + modules |= _load_modules(mod_name, self.mod_ignorexps) + 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) + self.py_undoc[mod_name] = {'error': err} + continue + + # if there are additional modules then we warn (but still scan) + additional_modules = set(seen_modules) - modules + if additional_modules: + logger.warning( + __('the following modules are documented but were not specified ' + 'in coverage_modules: %s'), + ', '.join(additional_modules), + ) + for mod_name in modules: ignore = False for exp in self.mod_ignorexps: @@ -213,7 +286,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 +302,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 +330,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: @@ -391,6 +464,7 @@ def finish(self) -> None: def setup(app: Sphinx) -> ExtensionMetadata: app.add_builder(CoverageBuilder) + app.add_config_value('coverage_modules', [], '') app.add_config_value('coverage_ignore_modules', [], '') app.add_config_value('coverage_ignore_functions', [], '') app.add_config_value('coverage_ignore_classes', [], '') From 422e29b1be398d3a31f0a8778bfab6c855201d28 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 5 Sep 2023 16:24:05 +0100 Subject: [PATCH 2/8] coverage: Sort modules This prevent the table changing every time we generate the report. Signed-off-by: Stephen Finucane --- sphinx/ext/coverage.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 4fb1ce1dfad..6b044ba2ce3 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -247,7 +247,7 @@ def build_py_coverage(self) -> None: ', '.join(additional_modules), ) - for mod_name in modules: + for mod_name in sorted(modules): ignore = False for exp in self.mod_ignorexps: if exp.match(mod_name): @@ -346,8 +346,7 @@ 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_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: @@ -357,7 +356,7 @@ def _write_py_statistics(self, op: TextIO) -> None: # prepare tabular table = [['Module', 'Coverage', 'Undocumented']] - for module in all_modules: + for module in sorted(all_modules): module_objects = self.py_documented[module].union(self.py_undocumented[module]) if len(module_objects): value = 100.0 * len(self.py_documented[module]) / len(module_objects) From 3c52a279b63a35d5da17f024d8e705b8f08a3158 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 5 Sep 2023 15:52:19 +0100 Subject: [PATCH 3/8] tests: Add tests for new coverage functionality We migrate the code we're measuring coverage for to a package so we can validate the new module coverage functionality. Signed-off-by: Stephen Finucane --- tests/roots/test-ext-coverage/conf.py | 5 ++- .../roots/test-ext-coverage/grog/__init__.py | 0 .../{ => grog}/coverage_ignored.py | 0 .../grog/coverage_missing.py | 7 ++++ .../{ => grog}/coverage_not_ignored.py | 0 tests/roots/test-ext-coverage/index.rst | 4 +- tests/test_extensions/test_ext_coverage.py | 40 +++++++++++++------ 7 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 tests/roots/test-ext-coverage/grog/__init__.py rename tests/roots/test-ext-coverage/{ => grog}/coverage_ignored.py (100%) create mode 100644 tests/roots/test-ext-coverage/grog/coverage_missing.py rename tests/roots/test-ext-coverage/{ => grog}/coverage_not_ignored.py (100%) 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: From 8eb2c552d33492db6e344e16c63ff65f3bca85bf Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Wed, 20 Sep 2023 22:45:28 +0100 Subject: [PATCH 4/8] coverage: Extract _determine_py_coverage_modules into a function --- sphinx/ext/coverage.py | 125 ++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 57 deletions(-) diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 6b044ba2ce3..58637098f0c 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -24,7 +24,7 @@ from sphinx.util.inspect import safe_getattr if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -77,34 +77,76 @@ def _load_modules(mod_name: str, ignored_module_exps: list[re.Pattern[str]]) -> :raises ImportError: If the module indicated by ``mod_name`` could not be loaded. """ - modules: set[str] = set() + if any(exp.match(mod_name) for exp in ignored_module_exps): + return set() - for exp in ignored_module_exps: - if exp.match(mod_name): - return modules - - # this can raise an exception but it's the responsibility of the caller to - # handle this + # 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 - modules.add(mod_name) - - for sub_mod_info in pkgutil.iter_modules(mod.__path__): - if sub_mod_info.name == '__main__': + 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_info.ispkg: - modules |= _load_modules(f'{mod_name}.{sub_mod_info.name}', ignored_module_exps) + if sub_mod_ispkg: + modules |= _load_modules(f'{mod_name}.{sub_mod_name}', ignored_module_exps) else: - for exp in ignored_module_exps: - if exp.match(sub_mod_info.name): - continue - - modules.add(f'{mod_name}.{sub_mod_info.name}') + 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: list[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() + 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 still scan) + additional_modules = set(seen_modules) - modules + if additional_modules: + logger.warning( + __('the following modules are documented but were not specified ' + 'in coverage_modules: %s'), + ', '.join(additional_modules), + ) + return sorted(modules) + + class CoverageBuilder(Builder): """ Evaluates coverage of code in the documentation. @@ -131,7 +173,6 @@ def init(self) -> None: for (name, exps) in self.config.coverage_ignore_c_items.items(): self.c_ignorexps[name] = compile_regex_list('coverage_ignore_c_items', exps) - self.module_names = self.config.coverage_modules self.mod_ignorexps = compile_regex_list('coverage_ignore_modules', self.config.coverage_ignore_modules) self.cls_ignorexps = compile_regex_list('coverage_ignore_classes', @@ -209,45 +250,15 @@ def ignore_pyobj(self, full_name: str) -> bool: ) def build_py_coverage(self) -> None: - seen_objects = self.env.domaindata['py']['objects'] - seen_modules = self.env.domaindata['py']['modules'] + seen_objects = set(self.env.domaindata['py']['objects']) + seen_modules = set(self.env.domaindata['py']['modules']) skip_undoc = self.config.coverage_skip_undoc_in_source - # 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 self.module_names: - modules = set(seen_modules) - else: - modules = set() - for mod_name in self.module_names: - try: - modules |= _load_modules(mod_name, self.mod_ignorexps) - 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) - self.py_undoc[mod_name] = {'error': err} - continue - - # if there are additional modules then we warn (but still scan) - additional_modules = set(seen_modules) - modules - if additional_modules: - logger.warning( - __('the following modules are documented but were not specified ' - 'in coverage_modules: %s'), - ', '.join(additional_modules), - ) - - for mod_name in sorted(modules): + 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: if exp.match(mod_name): @@ -463,7 +474,7 @@ def finish(self) -> None: def setup(app: Sphinx) -> ExtensionMetadata: app.add_builder(CoverageBuilder) - app.add_config_value('coverage_modules', [], '') + app.add_config_value('coverage_modules', (), '', [tuple, list, set]) app.add_config_value('coverage_ignore_modules', [], '') app.add_config_value('coverage_ignore_functions', [], '') app.add_config_value('coverage_ignore_classes', [], '') From 1d0f0bbca26ba88486727d4b1f6940fcd61682cd Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Fri, 6 Oct 2023 14:21:34 +0100 Subject: [PATCH 5/8] coverage: Warn on missing modules Signed-off-by: Stephen Finucane --- sphinx/ext/coverage.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 58637098f0c..48ca9097860 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -136,14 +136,22 @@ def _determine_py_coverage_modules( py_undoc[mod_name] = {'error': err} continue - # if there are additional modules then we warn (but still scan) - additional_modules = set(seen_modules) - modules - if additional_modules: + # if there are additional modules then we warn but continue scanning + if additional_modules := set(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 additional_modules := modules - set(seen_modules): + logger.warning( + __('the following modules are specified in coverage_modules ' + 'but were not documented'), + ', '.join(additional_modules), + ) + return sorted(modules) From c16453e88235bb1be839e5e36fe0bfce84decc02 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Fri, 6 Oct 2023 15:15:39 +0100 Subject: [PATCH 6/8] Fix typo Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- doc/usage/extensions/coverage.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/usage/extensions/coverage.rst b/doc/usage/extensions/coverage.rst index 3e24579d88b..0e9d79c91b9 100644 --- a/doc/usage/extensions/coverage.rst +++ b/doc/usage/extensions/coverage.rst @@ -57,7 +57,7 @@ check: ` or the :rst:dir:`automodule` directive provided by the :mod:`~sphinx.ext.autodoc` extension. - .. versionadded: 7.2 + .. versionadded:: 7.2 .. confval:: coverage_ignore_modules From 791e5b8a7d68690f70bf2c84cba08b121ec6ff7f Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:19:29 +0100 Subject: [PATCH 7/8] Tweaks, typing, etc --- doc/usage/extensions/coverage.rst | 54 +++++++++++++++++-------------- sphinx/ext/coverage.py | 40 +++++++++++------------ 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/doc/usage/extensions/coverage.rst b/doc/usage/extensions/coverage.rst index 0e9d79c91b9..9f241f71a41 100644 --- a/doc/usage/extensions/coverage.rst +++ b/doc/usage/extensions/coverage.rst @@ -11,51 +11,57 @@ This extension features one additional builder, the :class:`CoverageBuilder`. .. note:: 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. + 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. + :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. + 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. + 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 give ``-M coverage`` on the command line. +and run ``sphinx-build -M coverage`` on the command line. Builder ------- -.. class:: CoverageBuilder +.. py:class:: CoverageBuilder Configuration ------------- -Several configuration values can be used to specify what the builder should -check: +Several configuration values can be used to specify +what the builder should check: .. confval:: coverage_modules - - 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. + :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. .. versionadded:: 7.2 diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 48ca9097860..f7ce3beaa65 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -24,7 +24,7 @@ from sphinx.util.inspect import safe_getattr if TYPE_CHECKING: - from collections.abc import Iterator, Sequence + from collections.abc import Iterable, Iterator, Sequence, Set from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -67,7 +67,7 @@ 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: list[re.Pattern[str]]) -> set[str]: +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. @@ -103,8 +103,8 @@ def _load_modules(mod_name: str, ignored_module_exps: list[re.Pattern[str]]) -> def _determine_py_coverage_modules( coverage_modules: Sequence[str], - seen_modules: set[str], - ignored_module_exps: list[re.Pattern[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. @@ -122,11 +122,10 @@ def _determine_py_coverage_modules( 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() + modules: set[str] = set() for mod_name in coverage_modules: try: modules |= _load_modules(mod_name, ignored_module_exps) @@ -137,7 +136,7 @@ def _determine_py_coverage_modules( continue # if there are additional modules then we warn but continue scanning - if additional_modules := set(seen_modules) - modules: + if additional_modules := seen_modules - modules: logger.warning( __('the following modules are documented but were not specified ' 'in coverage_modules: %s'), @@ -145,11 +144,11 @@ def _determine_py_coverage_modules( ) # likewise, if there are missing modules we warn but continue scanning - if additional_modules := modules - set(seen_modules): + if missing_modules := modules - seen_modules: logger.warning( __('the following modules are specified in coverage_modules ' 'but were not documented'), - ', '.join(additional_modules), + ', '.join(missing_modules), ) return sorted(modules) @@ -195,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() @@ -258,8 +257,8 @@ def ignore_pyobj(self, full_name: str) -> bool: ) def build_py_coverage(self) -> None: - seen_objects = set(self.env.domaindata['py']['objects']) - seen_modules = set(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 @@ -366,17 +365,16 @@ def build_py_coverage(self) -> None: def _write_py_statistics(self, op: TextIO) -> None: """Outputs the table of ``op``.""" all_modules = frozenset(self.py_documented.keys() | self.py_undocumented.keys()) - all_objects: set[str] = set() - all_documented_objects: set[str] = set() + 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 sorted(all_modules): - module_objects = self.py_documented[module].union(self.py_undocumented[module]) + 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: @@ -482,7 +480,7 @@ def finish(self) -> None: def setup(app: Sphinx) -> ExtensionMetadata: app.add_builder(CoverageBuilder) - app.add_config_value('coverage_modules', (), '', [tuple, list, set]) + 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', [], '') From afd1496a2f0ca81ab7d481abfa826211b928f394 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:29:45 +0100 Subject: [PATCH 8/8] CHANGES --- CHANGES.rst | 3 +++ doc/usage/extensions/coverage.rst | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 9f241f71a41..75ffc0f59ed 100644 --- a/doc/usage/extensions/coverage.rst +++ b/doc/usage/extensions/coverage.rst @@ -63,7 +63,7 @@ what the builder should check: or the :rst:dir:`automodule` directive provided by the :mod:`~sphinx.ext.autodoc` extension. - .. versionadded:: 7.2 + .. versionadded:: 7.4 .. confval:: coverage_ignore_modules