Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sage
Path: blob/develop/src/bin/sage-coverage
4052 views
#!/usr/bin/env python3
import os
import sys
from tokenize import (NEWLINE, COMMENT, INDENT, DEDENT, STRING, NL,
                      NAME, OP, generate_tokens)
import argparse

parser = argparse.ArgumentParser(description='Look into Sage files for wrong doctests.')
parser.add_argument('filename', type=str, nargs='*', help='filename or a directory')
parser.add_argument('--all', action='store_true', help='give summary info about all files in the Sage library')
parser.add_argument('--only-bad', action='store_true', help='only print info for bad formatted files')
parser.add_argument('--summary', action='store_true', help='only print a short summary')

args = parser.parse_args()


def coverage_all(directory):
    os.chdir(directory)
    r = os.popen('sage-coverage * | grep SCORE').readlines()

    s = []
    scr = 0
    total = 0
    for x in r:
        y = x.lstrip('SCORE ')
        i = y.rfind(' of ')
        j = y.rfind(')')
        n = int(y[i+4:j])

        i = y.rfind(':')
        j = y.rfind('%')
        scr += float(y[i+1:j]) * float(n)

        total += n

        s.append(y)

    print(''.join(s))

    # Issue #5859: Don't crash if there isn't anything to test.
    score = 100.0
    if total != 0:
        score = (float(scr) / total)

    print("Overall weighted coverage score: {:.1f}%".format(score))
    print("Total number of functions: {}".format(total))

    # Print up to 3 doctest coverage goals.
    i = 0
    for goal in [70, 75, 80, 85, 90, 95, 99]:
        if score < goal:
            i += 1
            if i > 3: break
            need = int((goal*total - scr)/100.0)
            print("We need {:>4} more function{} to get to {}% coverage."
                  .format(need, "" if (need == 1) else "s", goal))

if args.all:
    if not args.filename:
        coverage_all(os.path.join(os.environ["SAGE_SRC"], 'sage'))
    elif len(args.filename) == 1:
        coverage_all(args.filename[0])
    else:
        print("sage-coverage: error: --all only accepts one filename argument")
        sys.exit(1)
    sys.exit(0)

if not args.filename:
    print("sage-coverage: error: if --all is not given, at least one filename argument is expected")
    sys.exit(1)

# Collect coverage results for one file
class CoverageResults:
    def __init__(self, filename=""):
        """
        INPUT:

        - ``filename`` -- name of the file, only for display purposes.
        """
        self.no_doc = []
        self.no_test = []
        self.good = []
        self.possibly_wrong = []
        self.filename = filename

    def report(self):
        """
        Print coverage results.
        """
        num_functions = len(self.good) + len(self.no_doc) + len(self.no_test)
        if not num_functions:
            print("No functions in", self.filename)
            return

        score = (100.0 * len(self.good)) / float(num_functions)

        print("SCORE {}: {:.1f}% ({} of {})".format(self.filename, score, len(self.good), num_functions))

        if self.no_doc:
            print("\nMissing documentation:")
            for f in self.no_doc:
                print("     *", f)
        if self.no_test:
            print("\nMissing doctests:")
            for f in self.no_test:
                print("     *", f)

        if self.possibly_wrong:
            print("\nPossibly wrong (function name doesn't occur in doctests):")
            for f in self.possibly_wrong:
                print("     *", f)


    def handle_function(self, name, fullname, docstring):
        """
        Check coverage of one function and store result.

        INPUT:

        - ``name`` -- bare function name (e.g. "foo")

        - ``fullname`` -- complete function definition (e.g. "def foo(arg=None)")

        - ``docstring`` -- the docstring, or ``None`` if there is no docstring
        """
        # Skip certain names
        if name in ['__dealloc__', '__new__', '_']:
            return

        if not docstring:
            self.no_doc.append(fullname)
            return

        if "pytest" in docstring:
            self.good.append(fullname)
            return

        if "sage: " not in docstring:
            self.no_test.append(fullname)
            return

        # If the name is of the form _xxx_, then the doctest is always
        # considered indirect.
        if name[0] == "_" and name[-1] == "_":
            is_indirect = True
        else:
            is_indirect = "indirect doctest" in docstring

        if not is_indirect and not name in docstring:
            self.possibly_wrong.append(fullname)
        self.good.append(fullname)


    def check_file(self, f):
        """
        Check the coverage of one file.

        INPUT:

        - ``f``: an open file

        OUTPUT: ``self``
        """
        # Where are we in a function definition?
        BEGINOFLINE = 0   # Beginning of new logical line
        UNKNOWN = -99     # Not at all in a function definition
        DEFNAMES = 1      # In function definition before first open paren
        DEFARGS = 2       # In function arguments or between closing paren and final colon
        DOCSTRING = -1    # Looking for docstring

        state = BEGINOFLINE

        # Previous token type seen
        prevtyp = NEWLINE

        # Indentation level
        indent = 0

        # Indentation level of last "def" statement
        # or None if no such statement.
        defindent = None

        for (typ, tok, start, end, logical_line) in generate_tokens(f.readline):
            # Completely ignore comments or continuation newlines
            if typ == COMMENT or typ == NL:
                continue

            # Handle indentation
            if typ == INDENT:
                indent += 1
                continue
            elif typ == DEDENT:
                indent -= 1
                if (defindent is not None and indent <= defindent):
                    defindent = None
                continue

            # Check for "def" or "cpdef" ("cdef" functions don't need to be documented).
            # Skip nested functions (with indent > defindent).
            if state == BEGINOFLINE:
                if typ == NAME and (tok in ["def", "cpdef"]) and (defindent is None or indent <= defindent):
                    state = DEFNAMES
                    deffullname = "line %s: "%start[0]
                    defparen = 0  # Number of open parentheses
                else:
                    state = UNKNOWN

            if state == DOCSTRING:
                if typ != NEWLINE:
                    docstring = None
                    if typ == STRING:
                        docstring = tok
                    self.handle_function(defname, deffullname, docstring)
                    state = UNKNOWN

            if state == DEFNAMES:
                if typ == NAME:
                    if tok == "class":  # Make sure that cdef classes are ignored
                        state = UNKNOWN
                    # Last NAME token before opening parenthesis is
                    # the function name.
                    defname = tok
                elif tok == '(':
                    state = DEFARGS
                else:
                    state = UNKNOWN

            if state == DEFARGS:
                if tok == '(':
                    defparen += 1
                elif tok == ')':
                    defparen -= 1
                elif defparen == 0 and tok == ':':
                    state = DOCSTRING
                    defindent = indent
                elif typ == NEWLINE:
                    state = UNKNOWN

            if state > 0:
                # Append tok string to deffullname
                if prevtyp == NAME and typ == NAME:
                    deffullname += ' '
                elif prevtyp == OP and deffullname[-1] in ",":
                    deffullname += ' '
                deffullname += tok

            # New line?
            if state == UNKNOWN and typ == NEWLINE:
                state = BEGINOFLINE

            prevtyp = typ

        return self


# Data reported by --summary
good           = 0
no_doc         = 0
no_test        = 0
possibly_wrong = 0
bad_files      = []

first = True


def go(filename):
    r"""
    If ``filename`` is a file, launch the inspector on this file. If
    ``filename`` is a directory then recursively launch this function on the
    files it contains.
    """
    if os.path.isdir(filename):
        for F in sorted(os.listdir(filename)):
            go(os.path.join(filename, F))
    if not os.path.exists(filename):
        print("File %s does not exist."%filename, file=sys.stderr)
        sys.exit(1)

    if not (filename.endswith('.py')
            or filename.endswith('.pyx')
            or filename.endswith('.sage')):
        return

    # Filter pytest files which are not supposed to have doctests
    if filename.endswith('_test.py'):
        return

    with open(filename, 'r') as f:
        cr = CoverageResults(filename).check_file(f)
    bad = cr.no_doc or cr.no_test or cr.possibly_wrong

    # Update the global variables
    if args.summary:
        global good, no_doc, no_test, possibly_wrong, bad_files
        no_doc         += len(cr.no_doc)
        no_test        += len(cr.no_test)
        possibly_wrong += len(cr.possibly_wrong)
        good           += len(cr.good)
        if bad:
            bad_files.append(filename)
        return
    if not bad and args.only_bad:
        return

    global first
    if first:
        print('-' * 72)
        first = False

    cr.report()  # Print the report
    print('-' * 72)


for arg in args.filename:
    go(arg)

if args.summary:
    num_functions = good + no_doc + no_test
    score = (100.0 * good) / float(num_functions)
    print("Global score: {:.1f}% ({} of {})\n".format(score, good, num_functions))
    print("{} files with wrong documentation".format(len(bad_files)))
    print("{} functions with no doc".format(no_doc))
    print("{} functions with no test".format(no_test))
    print("{} doctest are potentially wrong".format(possibly_wrong))
    print("\nFiles with wrong documentation:")
    print("-------------------------------")
    print("\n".join("  {}".format(filename) for filename in bad_files))