#!/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))