Skip to content

Commit

Permalink
Isolating much of the coverage code from junit running.
Browse files Browse the repository at this point in the history
This patch has no functional changes, being just a reorganization of existing coverage code. Isolating coverage code from junit_run will make future refactors (i.e. separating tasks) much easier to deal with, in addition to making code easier to read/understand.

There's still left-over cruft in junit_run that will go away (e.g. TaskExports) but this seemed like a good place to break-up the effort.

Testing Done:
Local runs of junit_run UTs and integration tests. Manual testing of emma, cobertura, and no coverage.

Bugs closed: 2375

Reviewed at https://rbcommons.com/s/twitter/r/2973/
  • Loading branch information
justin-trobec authored and stuhood committed Oct 16, 2015
1 parent bf5e1d8 commit aacb395
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 399 deletions.
13 changes: 13 additions & 0 deletions src/python/pants/backend/jvm/tasks/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,18 @@ python_library(
],
)

python_library(
name = 'coverage',
sources = globs('coverage/*.py'),
dependencies = [
'3rdparty/python/twitter/commons:twitter.common.dirutil',
':classpath_util',
'src/python/pants/base:build_environment',
'src/python/pants/util:dirutil',
'src/python/pants/util:strutil',
],
)

python_library(
name = 'junit_run',
sources = ['junit_run.py'],
Expand All @@ -297,6 +309,7 @@ python_library(
'src/python/pants/backend/jvm/subsystems:shader',
'src/python/pants/backend/jvm/targets:java',
'src/python/pants/backend/jvm/targets:jvm',
'src/python/pants/backend/jvm/tasks:coverage',
'src/python/pants/base:build_environment',
'src/python/pants/base:workunit',
'src/python/pants/binaries:binary_util',
Expand Down
Empty file.
141 changes: 141 additions & 0 deletions src/python/pants/backend/jvm/tasks/coverage/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# coding=utf-8
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

import os
import shutil
from abc import ABCMeta, abstractmethod, abstractproperty

from pants.backend.jvm.tasks.classpath_util import ClasspathUtil
from pants.util.dirutil import safe_mkdir
from pants.util.strutil import safe_shlex_split


class Coverage(object):
"""Base class for emma-like coverage processors. Do not instantiate."""
__metaclass__ = ABCMeta

@classmethod
def register_options(cls, register, register_jvm_tool):
register('--coverage-patterns', advanced=True, action='append',
help='Restrict coverage measurement. Values are class name prefixes in dotted form '
'with ? and * wildcards. If preceded with a - the pattern is excluded. For '
'example, to include all code in org.pantsbuild.raven except claws and the eye '
'you would use: {flag}=org.pantsbuild.raven.* {flag}=-org.pantsbuild.raven.claw '
'{flag}=-org.pantsbuild.raven.Eye.'.format(flag='--coverage_patterns'))
register('--coverage-jvm-options', advanced=True, action='append',
help='JVM flags to be added when running the coverage processor. For example: '
'{flag}=-Xmx4g {flag}=-XX:MaxPermSize=1g'.format(flag='--coverage-jvm-options'))
register('--coverage-open', action='store_true',
help='Open the generated HTML coverage report in a browser. Implies --coverage.')
register('--coverage-force', advanced=True, action='store_true',
help='Attempt to run the reporting phase of coverage even if tests failed '
'(defaults to False, as otherwise the coverage results would be unreliable).')

def __init__(self, task_exports, context):
options = task_exports.task_options
self._task_exports = task_exports
self._context = context
self._coverage = options.coverage
self._coverage_filters = options.coverage_patterns or []

self._coverage_jvm_options = []
for jvm_option in options.coverage_jvm_options:
self._coverage_jvm_options.extend(safe_shlex_split(jvm_option))

self._coverage_dir = os.path.join(task_exports.workdir, 'coverage')
self._coverage_instrument_dir = os.path.join(self._coverage_dir, 'classes')
# TODO(ji): These may need to be transferred down to the Emma class, as the suffixes
# may be emma-specific. Resolve when we also provide cobertura support.
self._coverage_metadata_file = os.path.join(self._coverage_dir, 'coverage.em')
self._coverage_file = os.path.join(self._coverage_dir, 'coverage.ec')
self._coverage_console_file = os.path.join(self._coverage_dir, 'coverage.txt')
self._coverage_xml_file = os.path.join(self._coverage_dir, 'coverage.xml')
self._coverage_html_file = os.path.join(self._coverage_dir, 'html', 'index.html')
self._coverage_open = options.coverage_open
self._coverage_force = options.coverage_force

@abstractmethod
def instrument(self, targets, tests, compute_junit_classpath, execute_java_for_targets):
pass

@abstractmethod
def report(self, targets, tests, execute_java_for_targets, tests_failed_exception):
pass

@abstractproperty
def classpath_prepend(self):
pass

@abstractproperty
def classpath_append(self):
pass

@abstractproperty
def extra_jvm_options(self):
pass

# Utility methods, called from subclasses
def is_coverage_target(self, tgt):
return (tgt.is_java or tgt.is_scala) and not tgt.is_test and not tgt.is_codegen

def get_coverage_patterns(self, targets):
if self._coverage_filters:
return self._coverage_filters
else:
classes_under_test = set()
classpath_products = self._context.products.get_data('runtime_classpath')

def add_sources_under_test(tgt):
if self.is_coverage_target(tgt):
contents = ClasspathUtil.classpath_contents(
(tgt,),
classpath_products,
confs=self._task_exports.confs,
transitive=False)
for f in contents:
clsname = ClasspathUtil.classname_for_rel_classfile(f)
if clsname:
classes_under_test.add(clsname)

for target in targets:
target.walk(add_sources_under_test)
return classes_under_test

def initialize_instrument_classpath(self, targets):
"""Clones the existing runtime_classpath and corresponding binaries to instrumentation specific
paths.
:param targets: the targets which should be mutated.
:returns the instrument_classpath ClasspathProducts containing the mutated paths.
"""
safe_mkdir(self._coverage_instrument_dir, clean=True)

runtime_classpath = self._context.products.get_data('runtime_classpath')
self._context.products.safe_create_data('instrument_classpath', runtime_classpath.copy)
instrumentation_classpath = self._context.products.get_data('instrument_classpath')

for target in targets:
if not self.is_coverage_target(target):
continue
paths = instrumentation_classpath.get_for_target(target, False)
for (config, path) in paths:
# there are two sorts of classpath entries we see in the compile classpath: jars and dirs
# the branches below handle the cloning of those respectively.
if os.path.isfile(path):
shutil.copy2(path, self._coverage_instrument_dir)
new_path = os.path.join(self._coverage_instrument_dir, os.path.basename(path))
else:
files = os.listdir(path)
for file in files:
shutil.copy2(file, self._coverage_instrument_dir)
new_path = self._coverage_instrument_dir

instrumentation_classpath.remove_for_target(target, [(config, path)])
instrumentation_classpath.add_for_target(target, [(config, new_path)])
self._context.log.debug(
"runtime_classpath ({}) mutated to instrument_classpath ({})".format(path, new_path))
return instrumentation_classpath
166 changes: 166 additions & 0 deletions src/python/pants/backend/jvm/tasks/coverage/cobertura.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# coding=utf-8
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

import os
from collections import defaultdict

from twitter.common.collections import OrderedSet

from pants.backend.jvm.targets.jar_dependency import JarDependency
from pants.backend.jvm.tasks.coverage.base import Coverage
from pants.base.build_environment import get_buildroot
from pants.base.exceptions import TaskError
from pants.binaries import binary_util
from pants.util.dirutil import relativize_paths, safe_delete, safe_mkdir, touch


class Cobertura(Coverage):
"""Class to run coverage tests with cobertura."""

@classmethod
def register_options(cls, register, register_jvm_tool):
slf4j_jar = JarDependency(org='org.slf4j', name='slf4j-simple', rev='1.7.5')

register('--coverage-cobertura-include-classes', advanced=True, action='append',
help='Regex patterns passed to cobertura specifying which classes should be '
'instrumented. (see the "includeclasses" element description here: '
'https://github.com/cobertura/cobertura/wiki/Ant-Task-Reference)')

register('--coverage-cobertura-exclude-classes', advanced=True, action='append',
help='Regex patterns passed to cobertura specifying which classes should NOT be '
'instrumented. (see the "excludeclasses" element description here: '
'https://github.com/cobertura/cobertura/wiki/Ant-Task-Reference')

def cobertura_jar(**kwargs):
return JarDependency(org='net.sourceforge.cobertura', name='cobertura', rev='2.1.1', **kwargs)

# The Cobertura jar needs all its dependencies when instrumenting code.
register_jvm_tool(register,
'cobertura-instrument',
classpath=[
cobertura_jar(),
slf4j_jar
])

# Instrumented code needs cobertura.jar in the classpath to run, but not most of the
# dependencies.
register_jvm_tool(register,
'cobertura-run',
classpath=[
cobertura_jar(intransitive=True),
slf4j_jar
])

register_jvm_tool(register, 'cobertura-report', classpath=[cobertura_jar()])

def __init__(self, task_exports, context):
super(Cobertura, self).__init__(task_exports, context)
options = task_exports.task_options
self._coverage_datafile = os.path.join(self._coverage_dir, 'cobertura.ser')
touch(self._coverage_datafile)
self._rootdirs = defaultdict(OrderedSet)
self._include_classes = options.coverage_cobertura_include_classes
self._exclude_classes = options.coverage_cobertura_exclude_classes
self._nothing_to_instrument = True

def instrument(self, targets, tests, compute_junit_classpath, execute_java_for_targets):
instrumentation_classpath = self.initialize_instrument_classpath(targets)
junit_classpath = compute_junit_classpath()
cobertura_cp = self._task_exports.tool_classpath('cobertura-instrument')
aux_classpath = os.pathsep.join(relativize_paths(junit_classpath, get_buildroot()))
safe_delete(self._coverage_datafile)
files_to_instrument = []
for target in targets:
if self.is_coverage_target(target):
paths = instrumentation_classpath.get_for_target(target, False)
for (name, path) in paths:
files_to_instrument.append(path)

if len(files_to_instrument) > 0:
self._nothing_to_instrument = False
args = [
'--datafile',
self._coverage_datafile,
'--auxClasspath',
aux_classpath,
]
# apply class incl/excl filters
if len(self._include_classes) > 0:
for pattern in self._include_classes:
args += ["--includeClasses", pattern]
else:
args += ["--includeClasses", '.*'] # default to instrumenting all classes
for pattern in self._exclude_classes:
args += ["--excludeClasses", pattern]

args += files_to_instrument

main = 'net.sourceforge.cobertura.instrument.InstrumentMain'
self._context.log.debug(
"executing cobertura instrumentation with the following args: {}".format(args))
result = execute_java_for_targets(targets,
classpath=cobertura_cp,
main=main,
jvm_options=self._coverage_jvm_options,
args=args,
workunit_factory=self._context.new_workunit,
workunit_name='cobertura-instrument')
if result != 0:
raise TaskError("java {0} ... exited non-zero ({1})"
" 'failed to instrument'".format(main, result))

@property
def classpath_append(self):
return ()

@property
def classpath_prepend(self):
return self._task_exports.tool_classpath('cobertura-run')

@property
def extra_jvm_options(self):
return ['-Dnet.sourceforge.cobertura.datafile=' + self._coverage_datafile]

def report(self, targets, tests, execute_java_for_targets, tests_failed_exception=None):
if self._nothing_to_instrument:
self._context.log.warn('Nothing found to instrument, skipping report...')
return
if tests_failed_exception:
self._context.log.warn('Test failed: {0}'.format(tests_failed_exception))
if self._coverage_force:
self._context.log.warn('Generating report even though tests failed.')
else:
return
cobertura_cp = self._task_exports.tool_classpath('cobertura-report')
source_roots = { t.target_base for t in targets if self.is_coverage_target(t) }
for report_format in ['xml', 'html']:
report_dir = os.path.join(self._coverage_dir, report_format)
safe_mkdir(report_dir, clean=True)
args = list(source_roots)
args += [
'--datafile',
self._coverage_datafile,
'--destination',
report_dir,
'--format',
report_format,
]
main = 'net.sourceforge.cobertura.reporting.ReportMain'
result = execute_java_for_targets(targets,
classpath=cobertura_cp,
main=main,
jvm_options=self._coverage_jvm_options,
args=args,
workunit_factory=self._context.new_workunit,
workunit_name='cobertura-report-' + report_format)
if result != 0:
raise TaskError("java {0} ... exited non-zero ({1})"
" 'failed to report'".format(main, result))

if self._coverage_open:
coverage_html_file = os.path.join(self._coverage_dir, 'html', 'index.html')
binary_util.ui_open(coverage_html_file)
Loading

0 comments on commit aacb395

Please sign in to comment.