Edgewall Software

source: trunk/bitten/build/pythontools.py @ 1001

Last change on this file since 1001 was 997, checked in by hodgestar, 13 years ago

Wrap lint report messages in a msg tag (includes a test and a documentation update). Fixes #547.

  • Property svn:eol-style set to native
File size: 19.5 KB
CovLine 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2005-2007 Christopher Lenz <cmlenz@gmx.de>
4# Copyright (C) 2008 Matt Good <matt@matt-good.net>
5# Copyright (C) 2008-2010 Edgewall Software
6# All rights reserved.
7#
8# This software is licensed as described in the file COPYING, which
9# you should have received as part of this distribution. The terms
10# are also available at http://bitten.edgewall.org/wiki/License.
11
112"""Recipe commands for tools commonly used by Python projects."""
13
114from __future__ import division
15
116import logging
117import os
118import cPickle as pickle
119import re
120try:
121    set
022except NameError:
023    from sets import Set as set
124import shlex
125import sys
26
127from bitten.build import CommandLine, FileSet
128from bitten.util import loc, xmlio
29
130log = logging.getLogger('bitten.build.pythontools')
31
132__docformat__ = 'restructuredtext en'
33
134def _python_path(ctxt):
35    """Return the path to the Python interpreter.
36   
37    If the configuration has a ``python.path`` property, the value of that
38    option is returned; otherwise the path to the current Python interpreter is
39    returned.
40    """
041    python_path = ctxt.config.get_filepath('python.path')
042    if python_path:
043        return python_path
044    return sys.executable
45
146def distutils(ctxt, file_='setup.py', command='build',
147              options=None, timeout=None):
48    """Execute a ``distutils`` command.
49   
50    :param ctxt: the build context
51    :type ctxt: `Context`
52    :param file\_: name of the file defining the distutils setup
53    :param command: the setup command to execute
54    :param options: additional options to pass to the command
55    :param timeout: the number of seconds before the external process should
56                    be aborted (has same constraints as CommandLine)
57    """
058    if options:
059        if isinstance(options, basestring):
060            options = shlex.split(options)
061    else:
062        options = []
63
064    if timeout:
065        timeout = int(timeout)
66
067    cmdline = CommandLine(_python_path(ctxt),
068                          [ctxt.resolve(file_), command] + options,
069                          cwd=ctxt.basedir)
070    log_elem = xmlio.Fragment()
071    error_logged = False
072    for out, err in cmdline.execute(timeout):
073        if out is not None:
074            log.info(out)
075            log_elem.append(xmlio.Element('message', level='info')[out])
076        if err is not None:
077            level = 'error'
078            if err.startswith('warning: '):
079                err = err[9:]
080                level = 'warning'
081                log.warning(err)
082            elif err.startswith('error: '):
083                ctxt.error(err[7:])
084                error_logged = True
085            else:
086                log.error(err)
087            log_elem.append(xmlio.Element('message', level=level)[err])
088    ctxt.log(log_elem)
89
090    if not error_logged and cmdline.returncode != 0:
091        ctxt.error('distutils failed (%s)' % cmdline.returncode)
92
193def exec_(ctxt, file_=None, module=None, function=None, 
194          output=None, args=None, timeout=None):
95    """Execute a Python script.
96   
97    Either the `file_` or the `module` parameter must be provided. If
98    specified using the `file_` parameter, the file must be inside the project
99    directory. If specified as a module, the module must either be resolvable
100    to a file, or the `function` parameter must be provided
101   
102    :param ctxt: the build context
103    :type ctxt: `Context`
104    :param file\_: name of the script file to execute
105    :param module: name of the Python module to execute
106    :param function: name of the Python function to run
107    :param output: name of the file to which output should be written
108    :param args: extra arguments to pass to the script
109    :param timeout: the number of seconds before the external process should
110                    be aborted (has same constraints as CommandLine)
111    """
0112    assert file_ or module, 'Either "file" or "module" attribute required'
0113    if function:
0114        assert module and not file_, '"module" attribute required for use of ' \
0115                                     '"function" attribute'
116
0117    if module:
118        # Script specified as module name, need to resolve that to a file,
119        # or use the function name if provided
0120        if function:
0121            args = '-c "import sys; from %s import %s; %s(sys.argv)" %s' % (
0122                   module, function, function, args)
0123        else:
0124            try:
0125                mod = __import__(module, globals(), locals(), [])
0126                components = module.split('.')
0127                for comp in components[1:]:
0128                    mod = getattr(mod, comp)
0129                file_ = mod.__file__.replace('\\', '/')
0130            except ImportError, e:
0131                ctxt.error('Cannot execute Python module %s: %s' % (module, e))
0132                return
133
0134    from bitten.build import shtools
0135    returncode = shtools.execute(ctxt, executable=_python_path(ctxt),
0136                                 file_=file_, output=output, args=args,
0137                                 timeout=timeout)
0138    if returncode != 0:
0139        ctxt.error('Executing %s failed (error code %s)' % \
0140                       (file_ or function or module, returncode))
141
1142def pylint(ctxt, file_=None):
143    """Extract data from a ``pylint`` run written to a file.
144   
145    :param ctxt: the build context
146    :type ctxt: `Context`
147    :param file\_: name of the file containing the Pylint output
148    """
3149    assert file_, 'Missing required attribute "file"'
3150    msg_re = re.compile(r'^(?P<file>.+):(?P<line>\d+): '
3151                        r'\[(?P<type>[A-Z]\d*)(?:, (?P<tag>[\w\.]+))?\] '
3152                        r'(?P<msg>.*)$')
3153    msg_categories = dict(W='warning', E='error', C='convention', R='refactor')
154
3155    problems = xmlio.Fragment()
3156    try:
3157        fd = open(ctxt.resolve(file_), 'r')
3158        try:
11159            for line in fd:
8160                match = msg_re.search(line)
8161                if match:
5162                    msg_type = match.group('type')
5163                    category = msg_categories.get(msg_type[0])
5164                    if len(msg_type) == 1:
5165                        msg_type = None
5166                    filename = match.group('file')
5167                    if os.path.isabs(filename) \
3168                            and filename.startswith(ctxt.basedir):
3169                        filename = filename[len(ctxt.basedir) + 1:]
5170                    filename = filename.replace('\\', '/')
5171                    lineno = int(match.group('line'))
5172                    tag = match.group('tag')
5173                    problems.append(xmlio.Element('problem', category=category,
5174                                                  type=msg_type, tag=tag,
5175                                                  line=lineno, file=filename)[
5176                        xmlio.Element('msg')[match.group('msg') or '']
5177                    ])
3178            ctxt.report('lint', problems)
3179        finally:
3180            fd.close()
0181    except IOError, e:
0182        log.warning('Error opening pylint results file (%s)', e)
183
1184def coverage(ctxt, summary=None, coverdir=None, include=None, exclude=None):
185    """Extract data from a ``coverage.py`` run.
186   
187    :param ctxt: the build context
188    :type ctxt: `Context`
189    :param summary: path to the file containing the coverage summary
190    :param coverdir: name of the directory containing the per-module coverage
191                     details
192    :param include: patterns of files or directories to include in the report
193    :param exclude: patterns of files or directories to exclude from the report
194    """
6195    assert summary, 'Missing required attribute "summary"'
196
4197    summary_line_re = re.compile(r'^(?P<module>.*?)\s+(?P<stmts>\d+)\s+'
4198                                 r'(?P<exec>\d+)\s+(?P<cov>\d+)%\s+'
4199                                 r'(?:(?P<missing>(?:\d+(?:-\d+)?(?:, )?)*)\s+)?'
4200                                 r'(?P<file>.+)$')
201
4202    fileset = FileSet(ctxt.basedir, include, exclude)
4203    missing_files = []
7204    for filename in fileset:
3205        if os.path.splitext(filename)[1] != '.py':
0206            continue
3207        missing_files.append(filename)
4208    covered_modules = set()
209
4210    try:
4211        summary_file = open(ctxt.resolve(summary), 'r')
4212        try:
4213            coverage = xmlio.Fragment()
19214            for summary_line in summary_file:
15215                match = summary_line_re.search(summary_line)
15216                if match:
3217                    modname = match.group(1)
3218                    filename = match.group(6)
3219                    if not os.path.isabs(filename):
2220                        filename = os.path.normpath(os.path.join(ctxt.basedir,
2221                                                                 filename))
2222                    else:
1223                        filename = os.path.realpath(filename)
3224                    if not filename.startswith(ctxt.basedir):
0225                        continue
3226                    filename = filename[len(ctxt.basedir) + 1:]
3227                    if not filename in fileset:
0228                        continue
229
3230                    percentage = int(match.group(4).rstrip('%'))
3231                    num_lines = int(match.group(2))
232
3233                    missing_files.remove(filename)
3234                    covered_modules.add(modname)
3235                    module = xmlio.Element('coverage', name=modname,
3236                                           file=filename.replace(os.sep, '/'),
3237                                           percentage=percentage,
3238                                           lines=num_lines)
3239                    coverage.append(module)
240
4241            for filename in missing_files:
0242                modname = os.path.splitext(filename.replace(os.sep, '.'))[0]
0243                if modname in covered_modules:
0244                    continue
0245                covered_modules.add(modname)
0246                module = xmlio.Element('coverage', name=modname,
0247                                       file=filename.replace(os.sep, '/'),
0248                                       percentage=0)
0249                coverage.append(module)
250
4251            ctxt.report('coverage', coverage)
4252        finally:
4253            summary_file.close()
0254    except IOError, e:
0255        log.warning('Error opening coverage summary file (%s)', e)
256
1257def trace(ctxt, summary=None, coverdir=None, include=None, exclude=None):
258    """Extract data from a ``trace.py`` run.
259   
260    :param ctxt: the build context
261    :type ctxt: `Context`
262    :param summary: path to the file containing the coverage summary
263    :param coverdir: name of the directory containing the per-module coverage
264                     details
265    :param include: patterns of files or directories to include in the report
266    :param exclude: patterns of files or directories to exclude from the report
267    """
5268    assert summary, 'Missing required attribute "summary"'
4269    assert coverdir, 'Missing required attribute "coverdir"'
270
3271    summary_line_re = re.compile(r'^\s*(?P<lines>\d+)\s+(?P<cov>\d+)%\s+'
3272                                 r'(?P<module>.*?)\s+\((?P<filename>.*?)\)')
3273    coverage_line_re = re.compile(r'\s*(?:(?P<hits>\d+): )?(?P<line>.*)')
274
3275    fileset = FileSet(ctxt.basedir, include, exclude)
3276    missing_files = []
5277    for filename in fileset:
2278        if os.path.splitext(filename)[1] != '.py':
0279            continue
2280        missing_files.append(filename)
3281    covered_modules = set()
282
3283    def handle_file(elem, sourcefile, coverfile=None):
2284        code_lines = set()
2285        for lineno, linetype, line in loc.count(sourcefile):
0286            if linetype == loc.CODE:
0287                code_lines.add(lineno)
2288        num_covered = 0
2289        lines = []
290
2291        if coverfile:
0292            prev_hits = '0'
0293            for idx, coverline in enumerate(coverfile):
0294                match = coverage_line_re.search(coverline)
0295                if match:
0296                    hits = match.group(1)
0297                    if hits: # Line covered
0298                        if hits != '0':
0299                            num_covered += 1
0300                        lines.append(hits)
0301                        prev_hits = hits
0302                    elif coverline.startswith('>'): # Line not covered
0303                        lines.append('0')
0304                        prev_hits = '0'
0305                    elif idx not in code_lines: # Not a code line
0306                        lines.append('-')
0307                        prev_hits = '0'
0308                    else: # A code line not flagged by trace.py
0309                        if prev_hits != '0':
0310                            num_covered += 1
0311                        lines.append(prev_hits)
312
0313            elem.append(xmlio.Element('line_hits')[' '.join(lines)])
314
2315        num_lines = not lines and len(code_lines) or \
2316                len([l for l in lines if l != '-'])
2317        if num_lines:
0318            percentage = int(round(num_covered * 100 / num_lines))
0319        else:
2320            percentage = 0
2321        elem.attr['percentage'] = percentage
2322        elem.attr['lines'] = num_lines
323
3324    try:
3325        summary_file = open(ctxt.resolve(summary), 'r')
3326        try:
3327            coverage = xmlio.Fragment()
10328            for summary_line in summary_file:
7329                match = summary_line_re.search(summary_line)
7330                if match:
2331                    modname = match.group(3)
2332                    filename = match.group(4)
2333                    if not os.path.isabs(filename):
1334                        filename = os.path.normpath(os.path.join(ctxt.basedir,
1335                                                                 filename))
1336                    else:
1337                        filename = os.path.realpath(filename)
2338                    if not filename.startswith(ctxt.basedir):
0339                        continue
2340                    filename = filename[len(ctxt.basedir) + 1:]
2341                    if not filename in fileset:
0342                        continue
343
2344                    missing_files.remove(filename)
2345                    covered_modules.add(modname)
2346                    module = xmlio.Element('coverage', name=modname,
2347                                           file=filename.replace(os.sep, '/'))
2348                    sourcefile = file(ctxt.resolve(filename))
2349                    try:
2350                        coverpath = ctxt.resolve(coverdir, modname + '.cover')
2351                        if os.path.isfile(coverpath):
0352                            coverfile = file(coverpath, 'r')
0353                        else:
2354                            log.warning('No coverage file for module %s at %s',
2355                                        modname, coverpath)
2356                            coverfile = None
2357                        try:
2358                            handle_file(module, sourcefile, coverfile)
2359                        finally:
2360                            if coverfile:
0361                                coverfile.close()
0362                    finally:
2363                        sourcefile.close()
2364                    coverage.append(module)
365
3366            for filename in missing_files:
0367                modname = os.path.splitext(filename.replace(os.sep, '.'))[0]
0368                if modname in covered_modules:
0369                    continue
0370                covered_modules.add(modname)
0371                module = xmlio.Element('coverage', name=modname,
0372                                       file=filename.replace(os.sep, '/'),
0373                                       percentage=0)
0374                filepath = ctxt.resolve(filename)
0375                fileobj = file(filepath, 'r')
0376                try:
0377                    handle_file(module, fileobj)
0378                finally:
0379                    fileobj.close()
0380                coverage.append(module)
381
3382            ctxt.report('coverage', coverage)
3383        finally:
3384            summary_file.close()
0385    except IOError, e:
0386        log.warning('Error opening coverage summary file (%s)', e)
387
1388def figleaf(ctxt, summary=None, include=None, exclude=None):
389    """Extract data from a ``Figleaf`` run.
390   
391    :param ctxt: the build context
392    :type ctxt: `Context`
393    :param summary: path to the file containing the coverage summary
394    :param include: patterns of files or directories to include in the report
395    :param exclude: patterns of files or directories to exclude from the report
396    """
5397    from figleaf import get_lines
5398    coverage = xmlio.Fragment()
5399    try:
5400        fileobj = open(ctxt.resolve(summary))
1401    except IOError, e:
1402        log.warning('Error opening coverage summary file (%s)', e)
1403        return
4404    coverage_data = pickle.load(fileobj)
4405    fileset = FileSet(ctxt.basedir, include, exclude)
7406    for filename in fileset:
3407        base, ext = os.path.splitext(filename)
3408        if ext != '.py':
1409            continue
2410        modname = base.replace(os.path.sep, '.')
2411        realfilename = ctxt.resolve(filename)
2412        interesting_lines = get_lines(open(realfilename))
2413        if not interesting_lines:
0414            continue
2415        covered_lines = coverage_data.get(realfilename, set())
2416        percentage = int(round(len(covered_lines) * 100 / len(interesting_lines)))
2417        line_hits = []
12418        for lineno in xrange(1, max(interesting_lines)+1):
10419            if lineno not in interesting_lines:
1420                line_hits.append('-')
9421            elif lineno in covered_lines:
3422                line_hits.append('1')
3423            else:
6424                line_hits.append('0')
2425        module = xmlio.Element('coverage', name=modname,
2426                               file=filename.replace(os.sep, '/'),
2427                               percentage=percentage,
2428                               lines=len(interesting_lines),
2429                               line_hits=' '.join(line_hits))
2430        coverage.append(module)
4431    ctxt.report('coverage', coverage)
432
1433def _normalize_filenames(ctxt, filenames, fileset):
2434    for filename in filenames:
1435        if not os.path.isabs(filename):
0436            filename = os.path.normpath(os.path.join(ctxt.basedir,
0437                                                     filename))
0438        else:
1439            filename = os.path.realpath(filename)
1440        if not filename.startswith(ctxt.basedir):
0441            continue
1442        filename = filename[len(ctxt.basedir) + 1:]
1443        if filename not in fileset:
0444            continue
1445        yield filename.replace(os.sep, '/')
446
1447def unittest(ctxt, file_=None):
448    """Extract data from a unittest results file in XML format.
449   
450    :param ctxt: the build context
451    :type ctxt: `Context`
452    :param file\_: name of the file containing the test results
453    """
5454    assert file_, 'Missing required attribute "file"'
455
4456    try:
4457        fileobj = file(ctxt.resolve(file_), 'r')
4458        try:
4459            total, failed = 0, 0
4460            results = xmlio.Fragment()
7461            for child in xmlio.parse(fileobj).children():
3462                test = xmlio.Element('test')
14463                for name, value in child.attr.items():
11464                    if name == 'file':
2465                        value = os.path.realpath(value)
2466                        if value.startswith(ctxt.basedir):
2467                            value = value[len(ctxt.basedir) + 1:]
2468                            value = value.replace(os.sep, '/')
2469                        else:
0470                            continue
11471                    test.attr[name] = value
11472                    if name == 'status' and value in ('error', 'failure'):
0473                        failed += 1
3474                for grandchild in child.children():
0475                    test.append(xmlio.Element(grandchild.name)[
0476                        grandchild.gettext()
0477                    ])
3478                results.append(test)
3479                total += 1
4480            if failed:
0481                ctxt.error('%d of %d test%s failed' % (failed, total,
0482                           total != 1 and 's' or ''))
4483            ctxt.report('test', results)
4484        finally:
4485            fileobj.close()
0486    except IOError, e:
0487        log.warning('Error opening unittest results file (%s)', e)
0488    except xmlio.ParseError, e:
0489        log.warning('Error parsing unittest results file (%s)', e)
Note: See TracBrowser for help on using the repository browser.