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