[Checkins] SVN: z3c.coverage/trunk/ Add a coveragediff script.

Marius Gedminas marius at pov.lt
Wed Jul 18 14:52:36 EDT 2007


Log message for revision 78120:
  Add a coveragediff script.
  
  

Changed:
  U   z3c.coverage/trunk/README.txt
  U   z3c.coverage/trunk/setup.py
  A   z3c.coverage/trunk/src/z3c/coverage/coveragediff.py

-=-
Modified: z3c.coverage/trunk/README.txt
===================================================================
--- z3c.coverage/trunk/README.txt	2007-07-18 17:18:58 UTC (rev 78119)
+++ z3c.coverage/trunk/README.txt	2007-07-18 18:52:35 UTC (rev 78120)
@@ -1,2 +1,5 @@
 This package produces a nice HTML representation of the coverage data
 generated by the Zope test runner.
+
+It also has a script to check for differences in coverage and report
+any regressions (increases in the number of untested lines).

Modified: z3c.coverage/trunk/setup.py
===================================================================
--- z3c.coverage/trunk/setup.py	2007-07-18 17:18:58 UTC (rev 78119)
+++ z3c.coverage/trunk/setup.py	2007-07-18 18:52:35 UTC (rev 78120)
@@ -23,7 +23,7 @@
 
 setup (
     name='z3c.coverage',
-    version='0.1.0',
+    version='0.2.0',
     author = "Zope Community",
     author_email = "zope3-dev at zope.org",
     description = "A script to visualize coverage reports via HTML",
@@ -56,6 +56,7 @@
     entry_points = """
         [console_scripts]
         coverage = z3c.coverage.coveragereport:main
+        coveragediff = z3c.coverage.coveragediff:main
         """,
     dependency_links = ['http://download.zope.org/distribution'],
     zip_safe = False,

Added: z3c.coverage/trunk/src/z3c/coverage/coveragediff.py
===================================================================
--- z3c.coverage/trunk/src/z3c/coverage/coveragediff.py	                        (rev 0)
+++ z3c.coverage/trunk/src/z3c/coverage/coveragediff.py	2007-07-18 18:52:35 UTC (rev 78120)
@@ -0,0 +1,276 @@
+#!/usr/bin/env python
+##############################################################################
+#
+# Copyright (c) 2007 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""
+Compare the test coverage between two versions.  The promary goal is to find
+regressions in test coverage (newly added lines of code without tests, or
+old lines of code that used to have tests but don't any more).
+
+Usage: coverage_diff.py [options] old-dir new-dir
+
+The directories are expected to contain files named '<package>.<module>.cover'
+with the format that Python's trace.py produces.
+"""
+
+import os
+import re
+import sys
+import smtplib
+import optparse
+from email.MIMEText import MIMEText
+
+
+try:
+    any
+except NameError:
+    # python 2.4 compatibility
+    def any(list):
+        for item in list:
+            if item:
+                return True
+        return False
+
+
+def matches(string, list_of_regexes):
+    """Check whether a string matches any of a list of regexes.
+
+        >>> matches('foo', map(re.compile, ['x', 'o']))
+        True
+        >>> matches('foo', map(re.compile, ['x', 'f$']))
+        False
+        >>> matches('foo', [])
+        False
+
+    """
+    return any(regex.search(string) for regex in list_of_regexes)
+
+
+def filter_files(files, include=(), exclude=()):
+    """Filters a file list by considering only the include patterns, then
+    excluding exclude patterns.  Patterns are regular expressions.
+
+    Examples:
+
+        >>> filter_files(['ivija.food', 'ivija.food.tests', 'other.ivija'],
+        ...              include=['^ivija'], exclude=['tests'])
+        ['ivija.food']
+
+        >>> filter_files(['ivija.food', 'ivija.food.tests', 'other.ivija'],
+        ...              exclude=['tests'])
+        ['ivija.food', 'other.ivija']
+
+        >>> filter_files(['ivija.food', 'ivija.food.tests', 'other.ivija'],
+        ...              include=['^ivija'])
+        ['ivija.food', 'ivija.food.tests']
+
+        >>> filter_files(['ivija.food', 'ivija.food.tests', 'other.ivija'])
+        ['ivija.food', 'ivija.food.tests', 'other.ivija']
+
+    """
+    if not include:
+        include = ['.'] # include everything by default
+    if not exclude:
+        exclude = []    # exclude nothing by default
+    include = map(re.compile, include)
+    exclude = map(re.compile, exclude)
+    return [fn for fn in files
+            if matches(fn, include) and not matches(fn, exclude)]
+
+
+def find_coverage_files(dir):
+    """Find all test coverage files in a given directory.
+
+    The files are expected to end in '.cover'.  Weird filenames produced
+    by tracing "fake" code (like '<doctest ...>') are ignored.
+    """
+    return [fn for fn in os.listdir(dir)
+            if fn.endswith('.cover') and not fn.startswith('<')]
+
+
+def filter_coverage_files(dir, include=(), exclude=()):
+    """Find test coverage files in a given directory matching given patterns.
+
+    The files are expected to end in '.cover'.  Weird filenames produced
+    by tracing "fake" code (like '<doctest ...>') are ignored.
+
+    Include/exclude patterns are regular expressions.  Include patterns
+    are considered first, then the results are trimmed by the exclude
+    patterns.
+    """
+    return filter_files(find_coverage_files(dir), include, exclude)
+
+
+def warn(filename, message):
+    """Warn about test coverage regression."""
+    module = strip(os.path.basename(filename), '.cover')
+    print '%s: %s' % (module, message)
+
+
+def compare_dirs(olddir, newdir, include=(), exclude=(), warn=warn):
+    """Compare two directories of coverage files."""
+    old_coverage_files = filter_coverage_files(olddir, include, exclude)
+    new_coverage_files = filter_coverage_files(newdir, include, exclude)
+
+    old_coverage_set = set(old_coverage_files)
+    for fn in sorted(new_coverage_files):
+        if fn in old_coverage_files:
+            compare_file(os.path.join(olddir, fn),
+                         os.path.join(newdir, fn), warn=warn)
+        else:
+            new_file(os.path.join(newdir, fn), warn=warn)
+
+
+def count_coverage(filename):
+    """Count the number of covered and uncovered lines in a file."""
+    covered = uncovered = 0
+    for line in file(filename):
+        if line.startswith('>>>>>>'):
+            uncovered += 1
+        elif len(line) >= 7 and not line.startswith(' '*7):
+            covered += 1
+    return covered, uncovered
+
+
+def compare_file(oldfile, newfile, warn=warn):
+    """Compare two coverage files."""
+    old_covered, old_uncovered = count_coverage(oldfile)
+    new_covered, new_uncovered = count_coverage(newfile)
+    if new_uncovered > old_uncovered:
+        increase = new_uncovered - old_uncovered
+        warn(newfile, "%d new lines of untested code" % increase)
+
+
+def new_file(newfile, warn=warn):
+    """Look for uncovered lines in a new coverage file."""
+    covered, uncovered = count_coverage(newfile)
+    if uncovered:
+        total = covered + uncovered
+        msg = "new file with %d lines of untested code (out of %d)" % (
+                    uncovered, total)
+        warn(newfile, msg)
+
+
+def strip(string, suffix):
+    """Strip a suffix from a string if it exists:
+
+        >>> strip('go bar a foobar', 'bar')
+        'go bar a foo'
+        >>> strip('go bar a foobar', 'baz')
+        'go bar a foobar'
+        >>> strip('allofit', 'allofit')
+        ''
+
+    """
+    if string.endswith(suffix):
+        string = string[:-len(suffix)]
+    return string
+
+
+class MailSender(object):
+    """Send emails over SMTP"""
+
+    def __init__(self, smtp_host='localhost', smtp_port=25):
+        self.smtp_host = smtp_host
+        self.smtp_port = smtp_port
+
+    def send_email(self, from_addr, to_addr, subject, body):
+        """Send an email."""
+        # Note that this won't handle non-ASCII characters correctly.
+        # See http://mg.pov.lt/blog/unicode-emails-in-python.html
+        msg = MIMEText(body)
+        if from_addr:
+            msg['From'] = from_addr
+        if to_addr:
+            msg['To'] = to_addr
+        msg['Subject'] = subject
+        smtp = smtplib.SMTP(self.smtp_host, self.smtp_port)
+        smtp.sendmail(from_addr, to_addr, msg.as_string())
+        smtp.quit()
+
+
+class ReportEmailer(object):
+    """Warning collector and emailer."""
+
+    def __init__(self, from_addr, to_addr, subject, mailer=None):
+        if not mailer:
+            mailer = MailSender()
+        self.from_addr = from_addr
+        self.to_addr = to_addr
+        self.subject = subject
+        self.mailer = mailer
+        self.warnings = []
+
+    def warn(self, filename, message):
+        """Warn about test coverage regression."""
+        module = strip(os.path.basename(filename), '.cover')
+        self.warnings.append('%s: %s' % (module, message))
+
+    def send(self):
+        """Send the warnings (if any)."""
+        if self.warnings:
+            body = '\n'.join(self.warnings)
+            self.mailer.send_email(self.from_addr, self.to_addr, self.subject,
+                                   body)
+
+
+
+def selftest():
+    """Run all unit tests in this module."""
+    import doctest
+    nfail, ntests = doctest.testmod()
+    if nfail == 0:
+        print "All %d tests passed." % ntests
+
+
+def main():
+    """Parse command line arguments and do stuff."""
+    progname = os.path.basename(sys.argv[0])
+    parser = optparse.OptionParser("usage: %prog olddir newdir",
+                                   prog=progname)
+    parser.add_option('--include', metavar='REGEX',
+                      help='only consider files matching REGEX',
+                      action='append')
+    parser.add_option('--exclude', metavar='REGEX',
+                      help='ignore files matching REGEX',
+                      action='append')
+    parser.add_option('--email', metavar='ADDR',
+                      help='send the report to a given email address'
+                           ' (only if regressions were found)',)
+    parser.add_option('--from', metavar='ADDR', dest='sender',
+                      help='set the email sender address')
+    parser.add_option('--subject', metavar='SUBJECT',
+                      default='Unit test coverage regression',
+                      help='set the email subject')
+    parser.add_option('--selftest', help='run integrity tests',
+                      action='store_true')
+    opts, args = parser.parse_args()
+    if opts.selftest:
+        selftest()
+        return
+    if len(args) != 2:
+        parser.error("wrong number of arguments")
+    olddir, newdir = args
+    if opts.email:
+        mailer = ReportEmailer(opts.sender, opts.email, opts.subject)
+        warnfunc = mailer.warn
+    else:
+        warnfunc = warn
+    compare_dirs(olddir, newdir, include=opts.include, exclude=opts.exclude,
+                 warn=warnfunc)
+    if opts.email:
+        mailer.send()
+
+
+if __name__ == '__main__':
+    main()


Property changes on: z3c.coverage/trunk/src/z3c/coverage/coveragediff.py
___________________________________________________________________
Name: svn:executable
   + *
Name: svn:keywords
   + Id



More information about the Checkins mailing list