Sunday, April 1, 2012

my own doctest runner

#!/cygdrive/c/Python27/python -i 'c:\crunch6SVN\python\pyTest.py'
#!/cygdrive/c/Python26_64/python  'c:\crunch6SVN\python\pyTest.py'
#!/usr/bin/env python
#!/usr/local/python/bin/python

# this works from powershell, but not from xterm or within spyder:
# C:\Python27\python ..\..\pyTest.py .\scanCoverage.py -g
# i think spyder puts in some trace hooks into pdb of its own
import sys
import os
#import ipdb
#dbg = ipdb.set_trace
from pdb import set_trace as dbg

def lineProfile(runStr,runContext={},module=None,moduleOnly=False):
    # with the run string set up, i can use cProfile to find the worst offenders
    import cProfile,pstats
    import line_profiler
    import sys
    prof = cProfile.Profile()
    #r = prof.runctx(runStr,{},{'p':p,'sout':sout})
    r = prof.runctx(runStr,{},runContext)
    # maybe use prof.dump_stats() to spit out to a file
    r = pstats.Stats(prof).strip_dirs().sort_stats('time').print_stats(5)

    #get line profiling on top 3 time hog functions
    ss = pstats.Stats(prof).sort_stats('time')
    def b(fn): return fn.rstrip('.py').rstrip('.pyc') #### rstrip takes or
    if moduleOnly:
        # only show functions in this file
        hogs = [f[2] for f in ss.fcn_list if b(f[0])==b(__file__)][:3]
        ts = [ss.stats[f][2] for f in ss.fcn_list if b(f[0])==b(__file__)][:3]
    else:
        #hogs = [f[2] for f in ss.fcn_list][:3]
        hogs = ss.fcn_list[:3]
        ts = [ss.stats[f][2] for f in ss.fcn_list][:3]
    fts = [t/ss.total_tt for t in ts]
    # ignore any functions beyond what accounts for 80% of the time
    for i in range(len(fts)):
        if sum(fts[:i])>.8: break
    hogs,ts,fts = hogs[:i],ts[:i],fts[:i]
    hogs.reverse();ts.reverse();fts.reverse() # i want longest time last
    # can't line prof builtins, so take them out of the list
    fts = [f for f,h in zip(fts,hogs) if not h[0]=='~']
    ts = [t for t,h in zip(ts,hogs) if not h[0]=='~']
    hogs = [h for h in hogs if not h[0]=='~']
    # this probably won't work in pyTest:
    #fs = [[getattr(x,h) for x in locals().values() if hasattr(x,h)][0]
    #fs = [[getattr(x,h) for x in sys.modules.values() if hasattr(x,h)][0]
    # pstats only saves module filename, so match files and search within them
    # rstrip for .pyc, .pyo
    modules = [x.__file__.rstrip('oc') for x in sys.modules.values() if hasattr(x,'__file__')]
    indices = [modules.index(h[0].rstrip('oc')) for h in hogs]
    modules = [x for x in sys.modules.values() if hasattr(x,'__file__')]
    hogMods = [modules[i] for i in indices]
    # find functions/methods within module
    #     only searches down one level instead of a full tree search, so don't
    #      get too crazy with deeply nested defs
    fs = []
    for ln,h,m in zip(*zip(*hogs)[1:3]+[hogMods]):
        #import pdb;pdb.set_trace()
        if hasattr(m,h) and hasattr(getattr(m,h),'__code__') and getattr(m,h).__code__.co_firstlineno == ln: fs.append(getattr(m,h))
        else:
            for a in [getattr(m,x) for x in dir(m)]:
                if hasattr(a,h) and hasattr(getattr(a,h),'__code__') and getattr(a,h).__code__.co_firstlineno == ln:
                    fs.append(getattr(a,h))
                    break
    #fs = [[getattr(x,h) for x in runContext.values() if hasattr(x,h)][0]
    #      for h in hogs]
    lprof = line_profiler.LineProfiler()
    for f in fs: lprof.add_function(f)
    #stats = lprof.runctx(runStr,{},{'p':p,'sout':sout}).get_stats()
    stats = lprof.runctx(runStr,{},runContext).get_stats()
    for ((fn,lineno,name),timings),ft in zip(sorted(stats.timings.items(),reverse=True),fts):
       line_profiler.show_func(fn,lineno,name,stats.timings[fn,lineno,name],stats.unit)
       print 'this function accounted for \033[0;31m%2.2f%%\033[m of total time'%(ft*100)
    #import pdb;pdb.set_trace()


# monkey patches to allow coverage analysis to work
#     just a little disturbing that (as of 2.4) doctest and trace coverage
#      don't work together...
def monkeypatchDoctest():
    # stolen from http://coltrane.bx.psu.edu:8192/svn/bx-python/trunk/setup.py
    #
    # Doctest and coverage don't get along, so we need to create
    # a monkeypatch that will replace the part of doctest that
    # interferes with coverage reports.
    #
    # The monkeypatch is based on this zope patch:
    # http://svn.zope.org/Zope3/trunk/src/zope/testing/doctest.py?rev=28679&r1=28703&r2=28705
    #
    try:
        import doctest
        _orp = doctest._OutputRedirectingPdb
        class NoseOutputRedirectingPdb(_orp):
            def __init__(self, out):
                self.__debugger_used = False
                _orp.__init__(self, out)

            def set_trace(self):
                self.__debugger_used = True
                #_orp.set_trace(self)
                pdb.Pdb.set_trace(self)

            def set_continue(self):
                # Calling set_continue unconditionally would break unit test coverage
                # reporting, as Bdb.set_continue calls sys.settrace(None).
                if self.__debugger_used:
                    #_orp.set_continue(self)
                    pdb.Pdb.set_continue(self)

        doctest._OutputRedirectingPdb = NoseOutputRedirectingPdb
    except:
        raise #pass
    return doctest

def monkeypatchTrace():
    import trace
    try:
        t = trace.Trace
        class NoDoctestCounts(t):
            def results(self):
                cs = self.counts
                newcs = {}
                # throw away 'files' that start with = 2.6 will not allow import by filename
        # i should refactor the whole thing to use imp module
        sys.path.insert(0,os.path.dirname(n))
        n = os.path.splitext(os.path.basename(n))[0]
    if not n.startswith('-'):
        if True:#try:
            if debug:
                # __import__ needs a non-empty fromlist if it's a submodule
                if '.' in n:
                    try: m = __import__(n,None,None,[True,])
                    except ImportError: # just run doctests for an object
                            modName = '.'.join(n.split('.')[:-1])
                            #objName = n.split('.')[-1]
                            m = __import__(modName,None,None,[True,])
                            #doctest.run_docstring_examples(m.__dict__[objName],m.__dict__,name=objName)
                            doctest.debug(m,n,True)
                            import sys
                            sys.exit()
                else: m = __import__(n)
                for i in m.__dict__.values():
                    import abc
                     # if it's a class (from a metaclass or metametaclass) or function
                    if type(i) == type or type(i) == abc.ABCMeta or \
                       (type(type(i)) == type and hasattr(i,'__name__')) \
                       or type(i) == type(lineProfile):
                        try:
                            print 'Testing',i.__name__
                            doctest.debug(m,n+'.'+i.__name__,True)
                        except ValueError:
                            print 'No doctests for', i.__name__
            else:
                import pdb
                if coverage:
                    #### need a better way to get module filenames without
                    #     importing them. (after initial import, the class and
                    #     def lines will not be executed, so will erroneously
                    #     be flagged as not tested.)
                    #d,name = os.path.split(m.__file__)
                    d,name = '.',n
                    #bn = trace.fullmodname(name)
                    bn = name.split('.')[-1]
                    # ignore all modules except the one being tested
                    ignoremods = []
                    mods = [trace.fullmodname(x) for x in os.listdir(d)]
                    for ignore,mod in zip([bn != x for x in mods], mods):
                        if ignore: ignoremods.append(mod)
                    tracer = trace.Trace(
                        ignoredirs=[sys.prefix, sys.exec_prefix],
                        ignoremods=ignoremods,
                        trace=0,
                        count=1)
                    if '.' in n:
                        tracer.run('m = __import__(n,None,None,[True,])')
                    else: tracer.run('m = __import__(n)')
                    tracer.run('doctest.testmod(m)')
                    r = tracer.results()
                    r.write_results(show_missing=True, coverdir='.')
                else:
                    # __import__ needs a non-empty fromlist if it's a submodule
                    if '.' in n:
                        try: m = __import__(n,None,None,[True,])
                        except ImportError: # just run doctests for an object
                            modName = '.'.join(n.split('.')[:-1])
                            objName = n.split('.')[-1]
                            m = __import__(modName,None,None,[True,])
                            doctest.run_docstring_examples(m.__dict__[objName],m.__dict__,name=objName)
                            import sys
                            sys.exit()
                    else:
                        #import pdb; pdb.set_trace()
                        m = __import__(n)
                    # dangerously convenient deletion of any old coverage files
                    try: os.remove(trace.modname(m.__file__)+'.cover')
                    except OSError: pass
                    # need to call profile function from the doctest
                    # so that it can set up the context and identify the run string, because anything not passed back will get garbage collected
                    # and there's no way to pass anything back
                    # but how can i call something within pyTest from the doctest string? some kind of callback?
                    # i want pyTest to decide if it gets called, so i can switch from the command line

                    doctest.testmod(m)
                    if profile:
                        runStr,runContext = m._profile()
                        lineProfile(runStr,runContext,m)
        else:#except Exception,e:
            print 'Could not test '+n
            print e
            raise e

q = quit
from sys import exit as e