Source code for docstr_coverage.coverage

"""The central module for coverage collection and file-walking"""

import os
import re
from ast import parse
from typing import Dict, List, Optional, Tuple

from tqdm import tqdm

from docstr_coverage.ignore_config import IgnoreConfig
from docstr_coverage.printers import LegacyPrinter
from docstr_coverage.result_collection import File, FileStatus, ResultCollection
from docstr_coverage.visitor import DocStringCoverageVisitor


def _do_ignore_node(filename: str, base_name: str, node_name: str, ignore_names: tuple) -> bool:
    """Determine whether a node (identified by its file, base, and own names) should be ignored

    Parameters
    ----------
    filename: String
        Name of the file containing `node_name`
    base_name: String
        Name of the node's parent node
    node_name: String
        Name of the node within the file. Usually a function name, class name, or a method name. In
        the case of method names, `node_name` will be only the method's name, while `base_name` will
        be of the form "<class_name>."
    ignore_names: Tuple[List[str], ...]
        Patterns of nodes to ignore. See :class:`docstr_coverage.ignore_config.IgnoreConfig`

    Returns
    -------
    Boolean
        True if the node should be ignored, else False"""
    filename = os.path.basename(filename).split(".")[0]

    for (file_regex, *name_regexes) in ignore_names:
        file_match = re.fullmatch(file_regex, filename)
        file_match = file_match.group() if file_match else None

        if file_match != filename:
            continue

        for name_regex in name_regexes:
            # Match on node name only
            name_match = re.fullmatch(name_regex, node_name)
            name_match = name_match.group() if name_match else None

            if name_match:
                return True

            # Match on node's period-delimited path: Its parent nodes (if any), plus the node name.
            #   This enables targeting i.e. the `__init__` method of a particular class, whereas
            #   the simple name match above would target `__init__` methods of all classes
            full_name_match = re.fullmatch(name_regex, "{}{}".format(base_name, node_name))
            full_name_match = full_name_match.group() if full_name_match else None

            if full_name_match:
                return True
    return False


def _analyze_docstrings_on_node(
    base: str,
    node: Tuple[str, bool, Optional[str], List],
    filename,
    ignore_config: IgnoreConfig,
    result_storage: File,
):
    """Track the existence of a docstring for `node`, and accumulate stats regarding
    expected and encountered docstrings for `node` and its children (if any).

    Parameters
    ----------
    base: String
        The name of this node's parent node
    node: Tuple triple of (String, Boolean, List)
        Information describing a node. `node[0]` is the node's name.
        `node[1]` is True if the node was properly documented,
        else False. `node[3]` is a list containing the node's children
        as triples of the same form (if it had any)
    filename: String
        String containing the name of the file.
    ignore_config: IgnoreConfig
        Information about which docstrings are to be ignored.
    result_storage: File
        The result-collection.File instance on which the observed
        docstring presence should be stored."""

    name, has_doc, decorator, child_nodes = node

    ##################################################
    # Check Current Node
    ##################################################

    # Check for ignore status
    ignore_reason = None
    if ignore_config.skip_init and name == "__init__":
        ignore_reason = "skip-init set to True"
    elif (
        ignore_config.skip_magic
        and name.startswith("__")
        and name.endswith("__")
        and name != "__init__"
    ):
        ignore_reason = "skip-magic set to True"
    elif ignore_config.skip_class_def and "_" not in name and (name[0] == name[0].upper()):
        ignore_reason = "skip-class-def set to True"
    elif ignore_config.skip_private and name.startswith("_") and not name.startswith("__"):
        ignore_reason = "skip-private set to True"
    elif ignore_config.ignore_names and _do_ignore_node(
        filename, base, name, ignore_config.ignore_names
    ):
        ignore_reason = "matching ignore pattern"
    elif ignore_config.skip_deleter and decorator == "@deleter":
        ignore_reason = "skip-deleter set to True"
    elif ignore_config.skip_property and decorator == "@property":
        ignore_reason = "skip-property set to True"
    elif ignore_config.skip_setter and decorator == "@setter":
        ignore_reason = "skip-setter set to True"

    # Set Result
    node_identifier = str(base) + str(name)
    result_storage.collect_docstring(
        identifier=node_identifier, has_docstring=has_doc, ignore_reason=ignore_reason
    )

    ##################################################
    # Check Child Nodes
    ##################################################
    for _symbol in child_nodes:
        _analyze_docstrings_on_node("%s." % name, _symbol, filename, ignore_config, result_storage)


[docs]def get_docstring_coverage( filenames: list, skip_magic: bool = False, skip_file_docstring: bool = False, skip_init: bool = False, skip_class_def: bool = False, skip_private: bool = False, verbose: int = 0, ignore_names: Tuple[List[str], ...] = (), ) -> Tuple[Dict, Dict]: """Checks contents of `filenames` for missing docstrings, and produces a report detailing docstring status. *Note*: For a method with a more expressive return type, you may want to try the experimental `docstr_coverage.analyze` function. Parameters ---------- filenames: List List of filename strings that are absolute or relative paths skip_magic: Boolean, default=False If True, skips all magic methods (double-underscore-prefixed), except '__init__' and does not include them in the report skip_file_docstring: Boolean, default=False If True, skips check for a module-level docstring skip_init: Boolean, default=False If True, skips methods named '__init__' and does not include them in the report skip_class_def: Boolean, default=False If True, skips class definitions and does not include them in the report. If this is True, the class's methods will still be checked skip_private: Boolean, default=False If True, skips function definitions beginning with a single underscore and does not include them in the report. verbose: Int in [0, 1, 2, 3], default=0 0) No printing. 1) Print total stats only. 2) Print stats for all files. 3) Print missing docstrings for all files. ignore_names: Tuple[List[str], ...], default=() Patterns to ignore when checking documentation. Each list in `ignore_names` defines a different pattern to be ignored. The first element in each list is the regular expression for matching filenames. All remaining arguments in each list are regexes for matching names of functions/classes. A node is ignored if it matches the filename regex and at least one of the remaining regexes Returns ------- Dict Links filename keys to a dict of stats for that filename. Example: >>> { ... '<filename>': { ... 'missing': ['<method_or_class_name>', '...'], ... 'module_doc': '<Boolean>', ... 'missing_count': '<missing_count int>', ... 'needed_count': '<needed_docstrings_count int>', ... 'coverage': '<percent_of_coverage float>', ... 'empty': '<Boolean>' ... }, ... ... } Dict Total summary stats for all files analyzed. Example: >>> { ... 'missing_count': '<total_missing_count int>', ... 'needed_count': '<total_needed_docstrings_count int>', ... 'coverage': '<total_percent_of_coverage float>' ... }""" ignore_config = IgnoreConfig( skip_magic=skip_magic, skip_file_docstring=skip_file_docstring, skip_init=skip_init, skip_class_def=skip_class_def, skip_private=skip_private, ignore_names=ignore_names, ) results = analyze(filenames, ignore_config) LegacyPrinter(verbosity=verbose, ignore_config=ignore_config).print(results) return results.to_legacy()
[docs]def analyze( filenames: list, ignore_config: IgnoreConfig = IgnoreConfig(), show_progress=True ) -> ResultCollection: """EXPERIMENTAL: More expressive alternative to `get_docstring_coverage`. Checks contents of `filenames` for missing docstrings, and produces a report detailing docstring status Note that this method, as well as its parameters and return types are still experimental and may change in future versions. Parameters ---------- filenames: List List of filename strings that are absolute or relative paths ignore_config: IgnoreConfig Information about which docstrings are to be ignored show_progress: Boolean, default=True If True, prints a progress bar to stdout Returns ------- ResultCollection The collected information about docstring presence""" results = ResultCollection() iterator = iter(filenames) if show_progress: iterator = tqdm( iterator, desc="Checking python files", unit="files", unit_scale=True, total=len(filenames), ) for filename in iterator: file_result = results.get_file(file_path=filename) ################################################## # Read and Parse Source ################################################## with open(filename, "r", encoding="utf-8") as f: source_tree = f.read() doc_visitor = DocStringCoverageVisitor(filename=filename) doc_visitor.visit(parse(source_tree)) _tree = doc_visitor.tree[0] ################################################## # Process Results ################################################## # _tree contains [<module docstring>, <is_empty: bool>, <symbols: classes and funcs>] if (not _tree[0]) and (not _tree[1]): if not ignore_config.skip_file_docstring: file_result.collect_module_docstring(has_docstring=False) else: file_result.collect_module_docstring( has_docstring=False, ignore_reason="--skip-file-docstring=True" ) elif _tree[1]: file_result.status = FileStatus.EMPTY else: file_result.collect_module_docstring(bool(_tree[0])) # Recursively traverse through functions and classes for symbol in _tree[-1]: _analyze_docstrings_on_node("", symbol, filename, ignore_config, file_result) return results