diff options
author | Jeremy Hylton <jeremy@alum.mit.edu> | 2003-02-18 15:06:17 (GMT) |
---|---|---|
committer | Jeremy Hylton <jeremy@alum.mit.edu> | 2003-02-18 15:06:17 (GMT) |
commit | 4edaa0d516dc6b5e3d739e45d55226bef8516512 (patch) | |
tree | f5d77be910c29e7c3dd588cc9e993926a6790fa4 | |
parent | 6fa635df7aa88ae9fd8b41ae42743341316c90f7 (diff) | |
download | cpython-4edaa0d516dc6b5e3d739e45d55226bef8516512.zip cpython-4edaa0d516dc6b5e3d739e45d55226bef8516512.tar.gz cpython-4edaa0d516dc6b5e3d739e45d55226bef8516512.tar.bz2 |
Copy the trace module here from Tools/scripts.
There are some problems with this module, but the tool works for
simple tasks and no one else has volunteered a better code coverage
tool. Should cleanup and document before the beta release.
-rw-r--r-- | Lib/trace.py | 729 |
1 files changed, 729 insertions, 0 deletions
diff --git a/Lib/trace.py b/Lib/trace.py new file mode 100644 index 0000000..ffbce5f --- /dev/null +++ b/Lib/trace.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python + +# portions copyright 2001, Autonomous Zones Industries, Inc., all rights... +# err... reserved and offered to the public under the terms of the +# Python 2.2 license. +# Author: Zooko O'Whielacronx +# http://zooko.com/ +# mailto:zooko@zooko.com +# +# Copyright 2000, Mojam Media, Inc., all rights reserved. +# Author: Skip Montanaro +# +# Copyright 1999, Bioreason, Inc., all rights reserved. +# Author: Andrew Dalke +# +# Copyright 1995-1997, Automatrix, Inc., all rights reserved. +# Author: Skip Montanaro +# +# Copyright 1991-1995, Stichting Mathematisch Centrum, all rights reserved. +# +# +# Permission to use, copy, modify, and distribute this Python software and +# its associated documentation for any purpose without fee is hereby +# granted, provided that the above copyright notice appears in all copies, +# and that both that copyright notice and this permission notice appear in +# supporting documentation, and that the name of neither Automatrix, +# Bioreason or Mojam Media be used in advertising or publicity pertaining to +# distribution of the software without specific, written prior permission. +# +"""program/module to trace Python program or function execution + +Sample use, command line: + trace.py -c -f counts --ignore-dir '$prefix' spam.py eggs + trace.py -t --ignore-dir '$prefix' spam.py eggs + +Sample use, programmatically + # create a Trace object, telling it what to ignore, and whether to + # do tracing or line-counting or both. + trace = trace.Trace(ignoredirs=[sys.prefix, sys.exec_prefix,], trace=0, + count=1) + # run the new command using the given trace + trace.run(coverage.globaltrace, 'main()') + # make a report, telling it where you want output + r = trace.results() + r.write_results(show_missing=1) +""" + +import sys, os, tempfile, types, copy, operator, inspect, exceptions, marshal +try: + import cPickle + pickle = cPickle +except ImportError: + import pickle + +# DEBUG_MODE=1 # make this true to get printouts which help you understand what's going on + +def usage(outfile): + outfile.write("""Usage: %s [OPTIONS] <file> [ARGS] + +Meta-options: +--help Display this help then exit. +--version Output version information then exit. + +Otherwise, exactly one of the following three options must be given: +-t, --trace Print each line to sys.stdout before it is executed. +-c, --count Count the number of times each line is executed + and write the counts to <module>.cover for each + module executed, in the module's directory. + See also `--coverdir', `--file', `--no-report' below. +-r, --report Generate a report from a counts file; do not execute + any code. `--file' must specify the results file to + read, which must have been created in a previous run + with `--count --file=FILE'. + +Modifiers: +-f, --file=<file> File to accumulate counts over several runs. +-R, --no-report Do not generate the coverage report files. + Useful if you want to accumulate over several runs. +-C, --coverdir=<dir> Directory where the report files. The coverage + report for <package>.<module> is written to file + <dir>/<package>/<module>.cover. +-m, --missing Annotate executable lines that were not executed + with '>>>>>> '. +-s, --summary Write a brief summary on stdout for each file. + (Can only be used with --count or --report.) + +Filters, may be repeated multiple times: +--ignore-module=<mod> Ignore the given module and its submodules + (if it is a package). +--ignore-dir=<dir> Ignore files in the given directory (multiple + directories can be joined by os.pathsep). +""" % sys.argv[0]) + +class Ignore: + def __init__(self, modules = None, dirs = None): + self._mods = modules or [] + self._dirs = dirs or [] + + self._dirs = map(os.path.normpath, self._dirs) + self._ignore = { '<string>': 1 } + + def names(self, filename, modulename): + if self._ignore.has_key(modulename): + return self._ignore[modulename] + + # haven't seen this one before, so see if the module name is + # on the ignore list. Need to take some care since ignoring + # "cmp" musn't mean ignoring "cmpcache" but ignoring + # "Spam" must also mean ignoring "Spam.Eggs". + for mod in self._mods: + if mod == modulename: # Identical names, so ignore + self._ignore[modulename] = 1 + return 1 + # check if the module is a proper submodule of something on + # the ignore list + n = len(mod) + # (will not overflow since if the first n characters are the + # same and the name has not already occured, then the size + # of "name" is greater than that of "mod") + if mod == modulename[:n] and modulename[n] == '.': + self._ignore[modulename] = 1 + return 1 + + # Now check that __file__ isn't in one of the directories + if filename is None: + # must be a built-in, so we must ignore + self._ignore[modulename] = 1 + return 1 + + # Ignore a file when it contains one of the ignorable paths + for d in self._dirs: + # The '+ os.sep' is to ensure that d is a parent directory, + # as compared to cases like: + # d = "/usr/local" + # filename = "/usr/local.py" + # or + # d = "/usr/local.py" + # filename = "/usr/local.py" + if filename.startswith(d + os.sep): + self._ignore[modulename] = 1 + return 1 + + # Tried the different ways, so we don't ignore this module + self._ignore[modulename] = 0 + return 0 + +class CoverageResults: + def __init__(self, counts=None, calledfuncs=None, infile=None, + outfile=None): + self.counts = counts + if self.counts is None: + self.counts = {} + self.counter = self.counts.copy() # map (filename, lineno) to count + self.calledfuncs = calledfuncs + if self.calledfuncs is None: + self.calledfuncs = {} + self.calledfuncs = self.calledfuncs.copy() + self.infile = infile + self.outfile = outfile + if self.infile: + # try and merge existing counts file + try: + thingie = pickle.load(open(self.infile, 'r')) + if type(thingie) is types.DictType: + # backwards compatibility for old trace.py after + # Zooko touched it but before calledfuncs --Zooko + # 2001-10-24 + self.update(self.__class__(thingie)) + elif type(thingie) is types.TupleType and len(thingie) == 2: + counts, calledfuncs = thingie + self.update(self.__class__(counts, calledfuncs)) + except (IOError, EOFError): + pass + except pickle.UnpicklingError: + # backwards compatibility for old trace.py before + # Zooko touched it --Zooko 2001-10-24 + self.update(self.__class__(marshal.load(open(self.infile)))) + + def update(self, other): + """Merge in the data from another CoverageResults""" + counts = self.counts + calledfuncs = self.calledfuncs + other_counts = other.counts + other_calledfuncs = other.calledfuncs + + for key in other_counts.keys(): + if key != 'calledfuncs': + # backwards compatibility for abortive attempt to + # stuff calledfuncs into self.counts, by Zooko + # --Zooko 2001-10-24 + counts[key] = counts.get(key, 0) + other_counts[key] + + for key in other_calledfuncs.keys(): + calledfuncs[key] = 1 + + def write_results(self, show_missing = 1, summary = 0, coverdir = None): + """ + @param coverdir + """ + for filename, modulename, funcname in self.calledfuncs.keys(): + print ("filename: %s, modulename: %s, funcname: %s" + % (filename, modulename, funcname)) + + import re + # turn the counts data ("(filename, lineno) = count") into something + # accessible on a per-file basis + per_file = {} + for thingie in self.counts.keys(): + if thingie != "calledfuncs": + # backwards compatibility for abortive attempt to + # stuff calledfuncs into self.counts, by Zooko --Zooko + # 2001-10-24 + filename, lineno = thingie + lines_hit = per_file[filename] = per_file.get(filename, {}) + lines_hit[lineno] = self.counts[(filename, lineno)] + + # there are many places where this is insufficient, like a blank + # line embedded in a multiline string. + blank = re.compile(r'^\s*(#.*)?$') + + # accumulate summary info, if needed + sums = {} + + # generate file paths for the coverage files we are going to write... + fnlist = [] + tfdir = tempfile.gettempdir() + for key in per_file.keys(): + filename = key + + # skip some "files" we don't care about... + if filename == "<string>": + continue + # are these caused by code compiled using exec or something? + if filename.startswith(tfdir): + continue + + modulename = inspect.getmodulename(filename) + + if filename.endswith(".pyc") or filename.endswith(".pyo"): + filename = filename[:-1] + + if coverdir: + thiscoverdir = coverdir + else: + thiscoverdir = os.path.dirname(os.path.abspath(filename)) + + # the code from here to "<<<" is the contents of the `fileutil.make_dirs()' function in the Mojo Nation project. --Zooko 2001-10-14 + # http://cvs.sourceforge.net/cgi-bin/viewcvs.cgi/mojonation/evil/common/fileutil.py?rev=HEAD&content-type=text/vnd.viewcvs-markup + tx = None + try: + os.makedirs(thiscoverdir) + except OSError, x: + tx = x + + if not os.path.isdir(thiscoverdir): + if tx: + raise tx + raise exceptions.IOError, "unknown error prevented creation of directory: %s" % thiscoverdir # careful not to construct an IOError with a 2-tuple, as that has a special meaning... + # <<< + + # build list file name by appending a ".cover" to the module name + # and sticking it into the specified directory + if "." in modulename: + # A module in a package + finalname = modulename.split(".")[-1] + listfilename = os.path.join(thiscoverdir, finalname + ".cover") + else: + listfilename = os.path.join(thiscoverdir, modulename + ".cover") + + # Get the original lines from the .py file + try: + lines = open(filename, 'r').readlines() + except IOError, err: + sys.stderr.write("trace: Could not open %s for reading because: %s - skipping\n" % (`filename`, err)) + continue + + try: + outfile = open(listfilename, 'w') + except IOError, err: + sys.stderr.write( + '%s: Could not open %s for writing because: %s" \ + "- skipping\n' % ("trace", `listfilename`, err)) + continue + + # If desired, get a list of the line numbers which represent + # executable content (returned as a dict for better lookup speed) + if show_missing: + executable_linenos = find_executable_linenos(filename) + else: + executable_linenos = {} + + n_lines = 0 + n_hits = 0 + lines_hit = per_file[key] + for i in range(len(lines)): + line = lines[i] + + # do the blank/comment match to try to mark more lines + # (help the reader find stuff that hasn't been covered) + if lines_hit.has_key(i+1): + # count precedes the lines that we captured + outfile.write('%5d: ' % lines_hit[i+1]) + n_hits = n_hits + 1 + n_lines = n_lines + 1 + elif blank.match(line): + # blank lines and comments are preceded by dots + outfile.write(' . ') + else: + # lines preceded by no marks weren't hit + # Highlight them if so indicated, unless the line contains + # '#pragma: NO COVER' (it is possible to embed this into + # the text as a non-comment; no easy fix) + if executable_linenos.has_key(i+1) and \ + lines[i].find(' '.join(['#pragma', 'NO COVER'])) == -1: + outfile.write('>>>>>> ') + else: + outfile.write(' '*7) + n_lines = n_lines + 1 + outfile.write(lines[i].expandtabs(8)) + + outfile.close() + + if summary and n_lines: + percent = int(100 * n_hits / n_lines) + sums[modulename] = n_lines, percent, modulename, filename + + if summary and sums: + mods = sums.keys() + mods.sort() + print "lines cov% module (path)" + for m in mods: + n_lines, percent, modulename, filename = sums[m] + print "%5d %3d%% %s (%s)" % sums[m] + + if self.outfile: + # try and store counts and module info into self.outfile + try: + pickle.dump((self.counts, self.calledfuncs), + open(self.outfile, 'w'), 1) + except IOError, err: + sys.stderr.write("cannot save counts files because %s" % err) + +def _find_LINENO_from_code(code): + """return the numbers of the lines containing the source code that + was compiled into code""" + linenos = {} + + line_increments = [ord(c) for c in code.co_lnotab[1::2]] + table_length = len(line_increments) + + lineno = code.co_firstlineno + + for li in line_increments: + linenos[lineno] = 1 + lineno += li + linenos[lineno] = 1 + + return linenos + +def _find_LINENO(code): + """return all of the lineno information from a code object""" + import types + + # get all of the lineno information from the code of this scope level + linenos = _find_LINENO_from_code(code) + + # and check the constants for references to other code objects + for c in code.co_consts: + if type(c) == types.CodeType: + # find another code object, so recurse into it + linenos.update(_find_LINENO(c)) + return linenos + +def find_executable_linenos(filename): + """return a dict of the line numbers from executable statements in a file + + """ + import parser + + assert filename.endswith('.py') + + prog = open(filename).read() + ast = parser.suite(prog) + code = parser.compileast(ast, filename) + + return _find_LINENO(code) + +### XXX because os.path.commonprefix seems broken by my way of thinking... +def commonprefix(dirs): + "Given a list of pathnames, returns the longest common leading component" + if not dirs: return '' + n = copy.copy(dirs) + for i in range(len(n)): + n[i] = n[i].split(os.sep) + prefix = n[0] + for item in n: + for i in range(len(prefix)): + if prefix[:i+1] <> item[:i+1]: + prefix = prefix[:i] + if i == 0: return '' + break + return os.sep.join(prefix) + +class Trace: + def __init__(self, count=1, trace=1, countfuncs=0, ignoremods=(), + ignoredirs=(), infile=None, outfile=None): + """ + @param count true iff it should count number of times each + line is executed + @param trace true iff it should print out each line that is + being counted + @param countfuncs true iff it should just output a list of + (filename, modulename, funcname,) for functions + that were called at least once; This overrides + `count' and `trace' + @param ignoremods a list of the names of modules to ignore + @param ignoredirs a list of the names of directories to ignore + all of the (recursive) contents of + @param infile file from which to read stored counts to be + added into the results + @param outfile file in which to write the results + """ + self.infile = infile + self.outfile = outfile + self.ignore = Ignore(ignoremods, ignoredirs) + self.counts = {} # keys are (filename, linenumber) + self.blabbed = {} # for debugging + self.pathtobasename = {} # for memoizing os.path.basename + self.donothing = 0 + self.trace = trace + self._calledfuncs = {} + if countfuncs: + self.globaltrace = self.globaltrace_countfuncs + elif trace and count: + self.globaltrace = self.globaltrace_lt + self.localtrace = self.localtrace_trace_and_count + elif trace: + self.globaltrace = self.globaltrace_lt + self.localtrace = self.localtrace_trace + elif count: + self.globaltrace = self.globaltrace_lt + self.localtrace = self.localtrace_count + else: + # Ahem -- do nothing? Okay. + self.donothing = 1 + + def run(self, cmd): + import __main__ + dict = __main__.__dict__ + if not self.donothing: + sys.settrace(self.globaltrace) + try: + exec cmd in dict, dict + finally: + if not self.donothing: + sys.settrace(None) + + def runctx(self, cmd, globals=None, locals=None): + if globals is None: globals = {} + if locals is None: locals = {} + if not self.donothing: + sys.settrace(self.globaltrace) + try: + exec cmd in globals, locals + finally: + if not self.donothing: + sys.settrace(None) + + def runfunc(self, func, *args, **kw): + result = None + if not self.donothing: + sys.settrace(self.globaltrace) + try: + result = apply(func, args, kw) + finally: + if not self.donothing: + sys.settrace(None) + return result + + def globaltrace_countfuncs(self, frame, why, arg): + """ + Handles `call' events (why == 'call') and adds the (filename, modulename, funcname,) to the self._calledfuncs dict. + """ + if why == 'call': + filename, lineno, funcname, context, lineindex = \ + inspect.getframeinfo(frame, 0) + if filename: + modulename = inspect.getmodulename(filename) + else: + modulename = None + self._calledfuncs[(filename, modulename, funcname,)] = 1 + + def globaltrace_lt(self, frame, why, arg): + """ + Handles `call' events (why == 'call') and if the code block being entered is to be ignored then it returns `None', else it returns `self.localtrace'. + """ + if why == 'call': + filename, lineno, funcname, context, lineindex = \ + inspect.getframeinfo(frame, 0) + if filename: + modulename = inspect.getmodulename(filename) + if modulename is not None: + ignore_it = self.ignore.names(filename, modulename) + if not ignore_it: + if self.trace: + print (" --- modulename: %s, funcname: %s" + % (modulename, funcname)) + return self.localtrace + else: + # XXX why no filename? + return None + + def localtrace_trace_and_count(self, frame, why, arg): + if why == 'line': + # record the file name and line number of every trace + # XXX I wish inspect offered me an optimized + # `getfilename(frame)' to use in place of the presumably + # heavier `getframeinfo()'. --Zooko 2001-10-14 + + filename, lineno, funcname, context, lineindex = \ + inspect.getframeinfo(frame, 1) + key = filename, lineno + self.counts[key] = self.counts.get(key, 0) + 1 + + # XXX not convinced that this memoizing is a performance + # win -- I don't know enough about Python guts to tell. + # --Zooko 2001-10-14 + + bname = self.pathtobasename.get(filename) + if bname is None: + + # Using setdefault faster than two separate lines? + # --Zooko 2001-10-14 + bname = self.pathtobasename.setdefault(filename, + os.path.basename(filename)) + try: + print "%s(%d): %s" % (bname, lineno, context[lineindex]), + except IndexError: + # Uh.. sometimes getframeinfo gives me a context of + # length 1 and a lineindex of -2. Oh well. + pass + return self.localtrace + + def localtrace_trace(self, frame, why, arg): + if why == 'line': + # XXX shouldn't do the count increment when arg is + # exception? But be careful to return self.localtrace + # when arg is exception! ? --Zooko 2001-10-14 + + # record the file name and line number of every trace XXX + # I wish inspect offered me an optimized + # `getfilename(frame)' to use in place of the presumably + # heavier `getframeinfo()'. --Zooko 2001-10-14 + filename, lineno, funcname, context, lineindex = \ + inspect.getframeinfo(frame) + + # XXX not convinced that this memoizing is a performance + # win -- I don't know enough about Python guts to tell. + # --Zooko 2001-10-14 + bname = self.pathtobasename.get(filename) + if bname is None: + # Using setdefault faster than two separate lines? + # --Zooko 2001-10-14 + bname = self.pathtobasename.setdefault(filename, os.path.basename(filename)) + if context is not None: + try: + print "%s(%d): %s" % (bname, lineno, context[lineindex]), + except IndexError: + # Uh.. sometimes getframeinfo gives me a context of length 1 and a lineindex of -2. Oh well. + pass + else: + print "%s(???): ???" % bname + return self.localtrace + + def localtrace_count(self, frame, why, arg): + if why == 'line': + filename = frame.f_code.co_filename + lineno = frame.f_lineno + key = filename, lineno + self.counts[key] = self.counts.get(key, 0) + 1 + return self.localtrace + + def results(self): + return CoverageResults(self.counts, infile=self.infile, + outfile=self.outfile, + calledfuncs=self._calledfuncs) + +def _err_exit(msg): + sys.stderr.write("%s: %s\n" % (sys.argv[0], msg)) + sys.exit(1) + +def main(argv=None): + import getopt + + if argv is None: + argv = sys.argv + try: + opts, prog_argv = getopt.getopt(argv[1:], "tcrRf:d:msC:l", + ["help", "version", "trace", "count", + "report", "no-report", "summary", + "file=", "missing", + "ignore-module=", "ignore-dir=", + "coverdir=", "listfuncs",]) + + except getopt.error, msg: + sys.stderr.write("%s: %s\n" % (sys.argv[0], msg)) + sys.stderr.write("Try `%s --help' for more information\n" + % sys.argv[0]) + sys.exit(1) + + trace = 0 + count = 0 + report = 0 + no_report = 0 + counts_file = None + missing = 0 + ignore_modules = [] + ignore_dirs = [] + coverdir = None + summary = 0 + listfuncs = False + + for opt, val in opts: + if opt == "--help": + usage(sys.stdout) + sys.exit(0) + + if opt == "--version": + sys.stdout.write("trace 2.0\n") + sys.exit(0) + + if opt == "-l" or opt == "--listfuncs": + listfuncs = True + continue + + if opt == "-t" or opt == "--trace": + trace = 1 + continue + + if opt == "-c" or opt == "--count": + count = 1 + continue + + if opt == "-r" or opt == "--report": + report = 1 + continue + + if opt == "-R" or opt == "--no-report": + no_report = 1 + continue + + if opt == "-f" or opt == "--file": + counts_file = val + continue + + if opt == "-m" or opt == "--missing": + missing = 1 + continue + + if opt == "-C" or opt == "--coverdir": + coverdir = val + continue + + if opt == "-s" or opt == "--summary": + summary = 1 + continue + + if opt == "--ignore-module": + ignore_modules.append(val) + continue + + if opt == "--ignore-dir": + for s in val.split(os.pathsep): + s = os.path.expandvars(s) + # should I also call expanduser? (after all, could use $HOME) + + s = s.replace("$prefix", + os.path.join(sys.prefix, "lib", + "python" + sys.version[:3])) + s = s.replace("$exec_prefix", + os.path.join(sys.exec_prefix, "lib", + "python" + sys.version[:3])) + s = os.path.normpath(s) + ignore_dirs.append(s) + continue + + assert 0, "Should never get here" + + if listfuncs and (count or trace): + _err_exit("cannot specify both --listfuncs and (--trace or --count)") + + if not count and not trace and not report and not listfuncs: + _err_exit("must specify one of --trace, --count, --report or --listfuncs") + + if report and no_report: + _err_exit("cannot specify both --report and --no-report") + + if report and not counts_file: + _err_exit("--report requires a --file") + + if no_report and len(prog_argv) == 0: + _err_exit("missing name of file to run") + + # everything is ready + if report: + results = CoverageResults(infile=counts_file, outfile=counts_file) + results.write_results(missing, summary=summary, coverdir=coverdir) + else: + sys.argv = prog_argv + progname = prog_argv[0] + sys.path[0] = os.path.split(progname)[0] + + t = Trace(count, trace, countfuncs=listfuncs, + ignoremods=ignore_modules, ignoredirs=ignore_dirs, + infile=counts_file, outfile=counts_file) + try: + t.run('execfile(' + `progname` + ')') + except IOError, err: + _err_exit("Cannot run file %s because: %s" % (`sys.argv[0]`, err)) + except SystemExit: + pass + + results = t.results() + + if not no_report: + results.write_results(missing, summary=summary, coverdir=coverdir) + +if __name__=='__main__': + main() |