diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
commit | cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a (patch) | |
tree | 18dcde1a8d1f5570a77cd0c361de3b490d02c789 /sphinx/ext/coverage.py | |
parent | Initial commit. (diff) | |
download | sphinx-cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a.tar.xz sphinx-cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a.zip |
Adding upstream version 7.2.6.upstream/7.2.6
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'sphinx/ext/coverage.py')
-rw-r--r-- | sphinx/ext/coverage.py | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py new file mode 100644 index 0000000..e3d9745 --- /dev/null +++ b/sphinx/ext/coverage.py @@ -0,0 +1,400 @@ +"""Check Python modules and C API for coverage. + +Mostly written by Josip Dzolonga for the Google Highly Open Participation +contest. +""" + +from __future__ import annotations + +import glob +import inspect +import pickle +import re +import sys +from importlib import import_module +from os import path +from typing import IO, TYPE_CHECKING, Any, TextIO + +import sphinx +from sphinx.builders import Builder +from sphinx.locale import __ +from sphinx.util import logging +from sphinx.util.console import red # type: ignore[attr-defined] +from sphinx.util.inspect import safe_getattr + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + + +# utility +def write_header(f: IO[str], text: str, char: str = '-') -> None: + f.write(text + '\n') + f.write(char * len(text) + '\n\n') + + +def compile_regex_list(name: str, exps: str) -> list[re.Pattern[str]]: + lst = [] + for exp in exps: + try: + lst.append(re.compile(exp)) + except Exception: + logger.warning(__('invalid regex %r in %s'), exp, name) + return lst + + +def _write_table(table: list[list[str]]) -> Iterator[str]: + sizes = [max(len(x[column]) for x in table) + 1 for column in range(len(table[0]))] + + yield _add_line(sizes, '-') + yield from _add_row(sizes, table[0], '=') + + for row in table[1:]: + yield from _add_row(sizes, row, '-') + + +def _add_line(sizes: list[int], separator: str) -> str: + return '+' + ''.join((separator * (size + 1)) + '+' for size in sizes) + + +def _add_row(col_widths: list[int], columns: list[str], separator: str) -> Iterator[str]: + yield ''.join(f'| {column: <{col_widths[i]}}' for i, column in enumerate(columns)) + '|' + yield _add_line(col_widths, separator) + + +class CoverageBuilder(Builder): + """ + Evaluates coverage of code in the documentation. + """ + name = 'coverage' + epilog = __('Testing of coverage in the sources finished, look at the ' + 'results in %(outdir)s' + path.sep + 'python.txt.') + + def init(self) -> None: + self.c_sourcefiles: list[str] = [] + for pattern in self.config.coverage_c_path: + pattern = path.join(self.srcdir, pattern) + self.c_sourcefiles.extend(glob.glob(pattern)) + + self.c_regexes: list[tuple[str, re.Pattern[str]]] = [] + for (name, exp) in self.config.coverage_c_regexes.items(): + try: + self.c_regexes.append((name, re.compile(exp))) + except Exception: + logger.warning(__('invalid regex %r in coverage_c_regexes'), exp) + + self.c_ignorexps: dict[str, list[re.Pattern[str]]] = {} + for (name, exps) in self.config.coverage_ignore_c_items.items(): + self.c_ignorexps[name] = compile_regex_list('coverage_ignore_c_items', + exps) + self.mod_ignorexps = compile_regex_list('coverage_ignore_modules', + self.config.coverage_ignore_modules) + self.cls_ignorexps = compile_regex_list('coverage_ignore_classes', + self.config.coverage_ignore_classes) + self.fun_ignorexps = compile_regex_list('coverage_ignore_functions', + self.config.coverage_ignore_functions) + self.py_ignorexps = compile_regex_list('coverage_ignore_pyobjects', + self.config.coverage_ignore_pyobjects) + + def get_outdated_docs(self) -> str: + return 'coverage overview' + + 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.build_py_coverage() + self.write_py_coverage() + + self.c_undoc: dict[str, set[tuple[str, str]]] = {} + self.build_c_coverage() + self.write_c_coverage() + + def build_c_coverage(self) -> None: + c_objects = {} + for obj in self.env.domains['c'].get_objects(): + c_objects[obj[2]] = obj[1] + for filename in self.c_sourcefiles: + undoc: set[tuple[str, str]] = set() + with open(filename, encoding="utf-8") as f: + for line in f: + for key, regex in self.c_regexes: + match = regex.match(line) + if match: + name = match.groups()[0] + if key not in c_objects: + undoc.add((key, name)) + continue + + if name not in c_objects[key]: + for exp in self.c_ignorexps.get(key, []): + if exp.match(name): + break + else: + undoc.add((key, name)) + continue + if undoc: + self.c_undoc[filename] = undoc + + def write_c_coverage(self) -> None: + output_file = path.join(self.outdir, 'c.txt') + with open(output_file, 'w', encoding="utf-8") as op: + if self.config.coverage_write_headline: + write_header(op, 'Undocumented C API elements', '=') + op.write('\n') + + for filename, undoc in self.c_undoc.items(): + write_header(op, filename) + for typ, name in sorted(undoc): + op.write(' * %-50s [%9s]\n' % (name, typ)) + if self.config.coverage_show_missing_items: + if self.app.quiet or self.app.warningiserror: + logger.warning(__('undocumented c api: %s [%s] in file %s'), + name, typ, filename) + else: + logger.info(red('undocumented ') + 'c ' + 'api ' + + '%-30s' % (name + " [%9s]" % typ) + + red(' - in file ') + filename) + op.write('\n') + + def ignore_pyobj(self, full_name: str) -> bool: + return any( + exp.search(full_name) + for exp in self.py_ignorexps + ) + + def build_py_coverage(self) -> None: + objects = self.env.domaindata['py']['objects'] + modules = self.env.domaindata['py']['modules'] + + skip_undoc = self.config.coverage_skip_undoc_in_source + + for mod_name in modules: + ignore = False + for exp in self.mod_ignorexps: + if exp.match(mod_name): + ignore = True + break + if ignore or self.ignore_pyobj(mod_name): + continue + + try: + mod = import_module(mod_name) + except ImportError as err: + logger.warning(__('module %s could not be imported: %s'), mod_name, err) + self.py_undoc[mod_name] = {'error': err} + continue + + documented_objects: set[str] = set() + undocumented_objects: set[str] = set() + + funcs = [] + classes: dict[str, list[str]] = {} + + for name, obj in inspect.getmembers(mod): + # diverse module attributes are ignored: + if name[0] == '_': + # begins in an underscore + continue + if not hasattr(obj, '__module__'): + # cannot be attributed to a module + continue + if obj.__module__ != mod_name: + # is not defined in this module + continue + + full_name = f'{mod_name}.{name}' + if self.ignore_pyobj(full_name): + continue + + if inspect.isfunction(obj): + if full_name not in objects: + for exp in self.fun_ignorexps: + if exp.match(name): + break + else: + if skip_undoc and not obj.__doc__: + continue + funcs.append(name) + undocumented_objects.add(full_name) + else: + documented_objects.add(full_name) + elif inspect.isclass(obj): + for exp in self.cls_ignorexps: + if exp.match(name): + break + else: + if full_name not in objects: + if skip_undoc and not obj.__doc__: + continue + # not documented at all + classes[name] = [] + continue + + attrs: list[str] = [] + + for attr_name in dir(obj): + if attr_name not in obj.__dict__: + continue + try: + attr = safe_getattr(obj, attr_name) + except AttributeError: + continue + if not (inspect.ismethod(attr) or + inspect.isfunction(attr)): + continue + if attr_name[0] == '_': + # starts with an underscore, ignore it + continue + if skip_undoc and not attr.__doc__: + # skip methods without docstring if wished + continue + full_attr_name = f'{full_name}.{attr_name}' + if self.ignore_pyobj(full_attr_name): + continue + if full_attr_name not in objects: + attrs.append(attr_name) + undocumented_objects.add(full_attr_name) + else: + documented_objects.add(full_attr_name) + + if attrs: + # some attributes are undocumented + classes[name] = attrs + + self.py_undoc[mod_name] = {'funcs': funcs, 'classes': classes} + self.py_undocumented[mod_name] = undocumented_objects + self.py_documented[mod_name] = documented_objects + + 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() + 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]) + + # prepare tabular + table = [['Module', 'Coverage', 'Undocumented']] + for module in 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) + else: + value = 100.0 + + table.append([module, '%.2f%%' % value, '%d' % len(self.py_undocumented[module])]) + table.append([ + 'TOTAL', + f'{100 * len(all_documented_objects) / len(all_objects):.2f}%', + f'{len(all_objects) - len(all_documented_objects)}', + ]) + + for line in _write_table(table): + op.write(f'{line}\n') + + def write_py_coverage(self) -> None: + output_file = path.join(self.outdir, 'python.txt') + failed = [] + with open(output_file, 'w', encoding="utf-8") as op: + if self.config.coverage_write_headline: + write_header(op, 'Undocumented Python objects', '=') + + if self.config.coverage_statistics_to_stdout: + self._write_py_statistics(sys.stdout) + + if self.config.coverage_statistics_to_report: + write_header(op, 'Statistics') + self._write_py_statistics(op) + op.write('\n') + + keys = sorted(self.py_undoc.keys()) + for name in keys: + undoc = self.py_undoc[name] + if 'error' in undoc: + failed.append((name, undoc['error'])) + else: + if not undoc['classes'] and not undoc['funcs']: + continue + + write_header(op, name) + if undoc['funcs']: + op.write('Functions:\n') + op.writelines(' * %s\n' % x for x in undoc['funcs']) + if self.config.coverage_show_missing_items: + if self.app.quiet or self.app.warningiserror: + for func in undoc['funcs']: + logger.warning( + __('undocumented python function: %s :: %s'), + name, func) + else: + for func in undoc['funcs']: + logger.info(red('undocumented ') + 'py ' + 'function ' + + '%-30s' % func + red(' - in module ') + name) + op.write('\n') + if undoc['classes']: + op.write('Classes:\n') + for class_name, methods in sorted( + undoc['classes'].items()): + if not methods: + op.write(' * %s\n' % class_name) + if self.config.coverage_show_missing_items: + if self.app.quiet or self.app.warningiserror: + logger.warning( + __('undocumented python class: %s :: %s'), + name, class_name) + else: + logger.info(red('undocumented ') + 'py ' + + 'class ' + '%-30s' % class_name + + red(' - in module ') + name) + else: + op.write(' * %s -- missing methods:\n\n' % class_name) + op.writelines(' - %s\n' % x for x in methods) + if self.config.coverage_show_missing_items: + if self.app.quiet or self.app.warningiserror: + for meth in methods: + logger.warning( + __('undocumented python method:' + + ' %s :: %s :: %s'), + name, class_name, meth) + else: + for meth in methods: + logger.info(red('undocumented ') + 'py ' + + 'method ' + '%-30s' % + (class_name + '.' + meth) + + red(' - in module ') + name) + op.write('\n') + + if failed: + write_header(op, 'Modules that failed to import') + op.writelines(' * %s -- %s\n' % x for x in failed) + + def finish(self) -> None: + # dump the coverage data to a pickle file too + picklepath = path.join(self.outdir, 'undoc.pickle') + with open(picklepath, 'wb') as dumpfile: + pickle.dump((self.py_undoc, self.c_undoc, + self.py_undocumented, self.py_documented), dumpfile) + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_builder(CoverageBuilder) + app.add_config_value('coverage_ignore_modules', [], False) + app.add_config_value('coverage_ignore_functions', [], False) + app.add_config_value('coverage_ignore_classes', [], False) + app.add_config_value('coverage_ignore_pyobjects', [], False) + app.add_config_value('coverage_c_path', [], False) + app.add_config_value('coverage_c_regexes', {}, False) + app.add_config_value('coverage_ignore_c_items', {}, False) + app.add_config_value('coverage_write_headline', True, False) + app.add_config_value('coverage_statistics_to_report', True, False, (bool,)) + app.add_config_value('coverage_statistics_to_stdout', True, False, (bool,)) + app.add_config_value('coverage_skip_undoc_in_source', False, False) + app.add_config_value('coverage_show_missing_items', False, False) + return {'version': sphinx.__display_version__, 'parallel_read_safe': True} |