New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Scoverage report generator #8098
Changes from 9 commits
ee80aeb
e25b659
7dd2807
6909d1b
e7edf04
a09f824
75b607b
cda9628
687430d
46fd95b
1124960
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,3 +14,5 @@ jar_library(name='scrooge-core', | |
scala_jar(org='com.twitter', name='scrooge-core', rev=SCROOGE_REV), | ||
], | ||
) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
# Modified scoverge plugin, which writes to classpath, currently consumed from Twitter forked scoverage here: | ||
# https://github.com/twitter-forks/scalac-scoverage-plugin. PR for the modifications | ||
# on original scoverage repo here: https://github.com/scoverage/scalac-scoverage-plugin/pull/267. | ||
# In future, we should ping OSS Scoverage to get that PR merged and consume scoverage directly | ||
# from there. | ||
|
||
jar_library(name='scalac-scoverage-plugin', | ||
jars=[ | ||
scala_jar(org='com.twitter.scoverage', name='scalac-scoverage-plugin', rev='1.0.1-twitter'), | ||
], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
# Copyright 2017 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
jar_library( | ||
name='slf4j-simple', | ||
jars=[ | ||
jar(org='org.slf4j', name='slf4j-simple', rev='1.7.26'), | ||
], | ||
) | ||
|
||
jar_library( | ||
name='slf4j-api', | ||
jars=[ | ||
jar(org='org.slf4j', name='slf4j-api', rev='1.7.26'), | ||
], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
scala_library( | ||
provides=scala_artifact( | ||
org='org.pantsbuild', | ||
name='scoverage-report-generator', | ||
repo=public, | ||
publication_metadata=pants_library('Report Generator for scoverage.') | ||
), | ||
dependencies = [ | ||
'3rdparty/jvm/commons-io', | ||
'3rdparty/jvm/com/github/scopt', | ||
'3rdparty/jvm/com/twitter/scoverage:scalac-scoverage-plugin', | ||
'3rdparty/jvm/org/slf4j:slf4j-simple', | ||
'3rdparty/jvm/org/slf4j:slf4j-api' | ||
], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
package org.pantsbuild.scoverage.report | ||
|
||
import java.io.File | ||
import org.apache.commons.io.FileUtils | ||
import java.util.concurrent.atomic.AtomicInteger | ||
|
||
import org.slf4j.Logger | ||
import org.slf4j.LoggerFactory | ||
|
||
import scoverage.{ Coverage, IOUtils, Serializer } | ||
import scoverage.report.{ ScoverageHtmlWriter, ScoverageXmlWriter } | ||
|
||
object ScoverageReport { | ||
val Scoverage = "scoverage" | ||
|
||
// Setting the logger | ||
val logger: Logger = LoggerFactory.getLogger(Scoverage) | ||
|
||
/** | ||
* | ||
* @param dataDirs list of measurement directories for which coverage | ||
* report has to be generated | ||
* @return [Coverage] object for the [dataDirs] | ||
*/ | ||
private def aggregatedCoverage(dataDirs: Seq[File]): Coverage = { | ||
var id = new AtomicInteger(0) | ||
val coverage = Coverage() | ||
dataDirs foreach { dataDir => | ||
val coverageFile: File = Serializer.coverageFile(dataDir) | ||
if (coverageFile.exists) { | ||
val subcoverage: Coverage = Serializer.deserialize(coverageFile) | ||
val measurementFiles: Array[File] = IOUtils.findMeasurementFiles(dataDir) | ||
val measurements = IOUtils.invoked(measurementFiles.toIndexedSeq) | ||
subcoverage.apply(measurements) | ||
subcoverage.statements foreach { stmt => | ||
// need to ensure all the ids are unique otherwise the coverage object will have stmt collisions | ||
coverage add stmt.copy(id = id.incrementAndGet()) | ||
} | ||
} | ||
} | ||
coverage | ||
} | ||
|
||
/** | ||
* | ||
* @param dataDirs list of measurement directories for which coverage | ||
* report has to be generated | ||
* @return Coverage object | ||
*/ | ||
private def aggregate(dataDirs: Seq[File]): Coverage = { | ||
logger.info(s"Found ${dataDirs.size} subproject scoverage data directories [${dataDirs.mkString(",")}]") | ||
if (dataDirs.nonEmpty) { | ||
aggregatedCoverage(dataDirs) | ||
} else { | ||
throw new RuntimeException(s"Datadir ${dataDirs.mkString(",")} empty") | ||
} | ||
} | ||
|
||
/** | ||
* | ||
* @param dataDir root directory to search under | ||
* @return all the directories and subdirs containing scoverage files beginning at [dataDir] | ||
*/ | ||
def getAllCoverageDirs(dataDir: File, acc: Seq[File]): Seq[File] = { | ||
if (dataDir.listFiles.filter(_.isFile).toSeq.exists(_.getName contains "scoverage.coverage")) { | ||
dataDir.listFiles.filter(_.isDirectory).toSeq | ||
.foldRight(dataDir +: acc) { (e, a) => getAllCoverageDirs(e, a) } | ||
} else { | ||
dataDir.listFiles.filter(_.isDirectory).toSeq | ||
.foldRight(acc) { (e, a) => getAllCoverageDirs(e, a) } | ||
} | ||
} | ||
/** | ||
* Select the appropriate directories for which the scoverage report has | ||
* to be generated. If [targetFiles] is empty, report is generated for all | ||
* measurements directories inside in [dataDir]. | ||
*/ | ||
def filterFiles(dataDir: File, settings: Settings): Seq[File] = { | ||
val targetFiles = settings.targetFilters | ||
|
||
val coverageDirs = getAllCoverageDirs(dataDir, Seq()) | ||
|
||
if (targetFiles.nonEmpty) { | ||
logger.info(s"Looking for targets: $targetFiles") | ||
coverageDirs.filter { | ||
file => targetFiles.exists(file.toString contains _) | ||
} | ||
} else { | ||
coverageDirs | ||
} | ||
} | ||
|
||
/** | ||
* Aggregating coverage from all the coverage measurements. | ||
*/ | ||
private def loadAggregatedCoverage(dataPath: String, settings: Settings): Coverage = { | ||
val dataDir: File = new File(dataPath) | ||
logger.info(s"Attempting to open scoverage data dir: [$dataDir]") | ||
if (dataDir.exists) { | ||
logger.info(s"Aggregating coverage.") | ||
val dataDirs: Seq[File] = filterFiles(dataDir, settings) | ||
aggregate(dataDirs) | ||
} else { | ||
logger.error("Coverage directory does not exist.") | ||
throw new RuntimeException("Coverage directory does not exist.") | ||
} | ||
} | ||
|
||
/** | ||
* Loads coverage data from the specified data directory. | ||
*/ | ||
private def loadCoverage(dataPath: String): Coverage = { | ||
val dataDir: File = new File(dataPath) | ||
logger.info(s"Attempting to open scoverage data dir [$dataDir]") | ||
|
||
if (dataDir.exists) { | ||
val coverageFile = Serializer.coverageFile(dataDir) | ||
logger.info(s"Reading scoverage instrumentation [$coverageFile]") | ||
|
||
coverageFile.exists match { | ||
case true => | ||
val coverage = Serializer.deserialize(coverageFile) | ||
logger.info(s"Reading scoverage measurements...") | ||
|
||
val measurementFiles = IOUtils.findMeasurementFiles(dataDir) | ||
val measurements = IOUtils.invoked(measurementFiles) | ||
coverage.apply(measurements) | ||
coverage | ||
|
||
case false => | ||
logger.error("Coverage file did not exist") | ||
throw new RuntimeException("Coverage file did not exist") | ||
|
||
} | ||
} else { | ||
logger.error("Data dir did not exist!") | ||
throw new RuntimeException("Data dir did not exist!") | ||
} | ||
|
||
} | ||
|
||
/** | ||
* Writes coverage reports usign the specified source path to the specified report directory. | ||
*/ | ||
private def writeReports(coverage: Coverage, settings: Settings): Unit = { | ||
val sourceDir = new File(settings.sourceDirPath) | ||
val reportDir = new File(settings.reportDirPath) | ||
val reportDirHtml = new File(settings.reportDirPath + "/html") | ||
val reportDirXml = new File(settings.reportDirPath + "/xml") | ||
|
||
if (sourceDir.exists) { | ||
if (settings.cleanOldReports && reportDir.exists) { | ||
logger.info(s"Nuking old report directory [$reportDir].") | ||
FileUtils.deleteDirectory(reportDir) | ||
} | ||
|
||
if (!reportDir.exists) { | ||
logger.info(s"Creating HTML report directory [$reportDirHtml]") | ||
reportDirHtml.mkdirs | ||
logger.info(s"Creating XML report directory [$reportDirXml]") | ||
reportDirXml.mkdirs | ||
} | ||
|
||
if (settings.writeHtmlReport) { | ||
logger.info(s"Writing HTML scoverage reports to [$reportDirHtml]") | ||
new ScoverageHtmlWriter(Seq(sourceDir), reportDirHtml, None).write(coverage) | ||
} | ||
|
||
if (settings.writeXmlReport) { | ||
logger.info(s"Writing XML scoverage reports to [$reportDirXml]") | ||
new ScoverageXmlWriter(Seq(sourceDir), reportDirXml, false).write(coverage) | ||
if (settings.writeXmlDebug) { | ||
new ScoverageXmlWriter(Seq(sourceDir), reportDirXml, true).write(coverage) | ||
} | ||
} | ||
} else { | ||
logger.error(s"Source dir [$sourceDir] does not exist") | ||
throw new RuntimeException(s"Source dir [$sourceDir] does not exist") | ||
} | ||
|
||
logger.info(s"Statement coverage: ${coverage.statementCoverageFormatted}%") | ||
logger.info(s"Branch coverage: ${coverage.branchCoverageFormatted}%") | ||
} | ||
|
||
def main(args: Array[String]): Unit = { | ||
Settings.parser1.parse(args, Settings()) match { | ||
case Some(settings) => | ||
val writeScoverageReports = settings.writeHtmlReport || settings.writeXmlReport | ||
|
||
settings.loadDataDir match { | ||
case false => | ||
val cov = loadAggregatedCoverage(settings.measurementsDirPath, settings) | ||
logger.info("Coverage loaded successfully.") | ||
if (writeScoverageReports) { | ||
writeReports(cov, settings) | ||
} | ||
case true => | ||
val cov = loadCoverage(settings.dataDirPath) | ||
logger.info("Coverage loaded successfully!") | ||
if (writeScoverageReports) { | ||
writeReports(cov, settings) | ||
} | ||
} | ||
|
||
case None => throw new RuntimeException("ScoverageReport: Incorrect options supplied.") | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
package org.pantsbuild.scoverage.report | ||
|
||
/** | ||
* All parsed command-line options. | ||
*/ | ||
case class Settings( | ||
loadDataDir: Boolean = false, | ||
measurementsDirPath: String = "", | ||
reportDirPath: String = "", | ||
sourceDirPath: String = ".", | ||
dataDirPath: String = "", | ||
writeHtmlReport: Boolean = true, | ||
writeXmlReport: Boolean = true, | ||
writeXmlDebug: Boolean = false, | ||
cleanOldReports: Boolean = true, | ||
targetFilters: Seq[String] = Seq()) | ||
|
||
object Settings { | ||
|
||
val parser1 = new scopt.OptionParser[Settings]("scoverage") { | ||
head("scoverageReportGenerator") | ||
|
||
help("help") | ||
.text("Print this usage message.") | ||
|
||
opt[Unit]("loadDataDir") | ||
.action((_, s: Settings) => s.copy(loadDataDir = true)) | ||
.text("Load a single measurements directory instead of aggregating coverage reports. Must pass in `dataDirPath <dir>`") | ||
|
||
opt[String]("measurementsDirPath") | ||
.action((dir: String, s: Settings) => s.copy(measurementsDirPath = dir)) | ||
.text("Directory where all scoverage measurements data is stored.") | ||
|
||
opt[String]("reportDirPath") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than taking a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently, if both options are on, it creates There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's more confusing than just letting the caller choose where to put each thing. |
||
.action((dir: String, s: Settings) => s.copy(reportDirPath = dir)) | ||
.text("Target output directory to place the reports.") | ||
|
||
opt[String]("sourceDirPath") | ||
.action((dir: String, s: Settings) => s.copy(sourceDirPath = dir)) | ||
.text("Directory containing the project sources.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Project sources" is pretty ambiguous... is this tool used per-target? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, its just there for backwards compatibility. I don't think there is any use for this tool for latest ReportWriters (or in my report generator): Since its still necessary to pass it in as a parameter, I just pass in the current directory by default. |
||
|
||
opt[String]("dataDirPath") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like this option and loadDataDir are tightly bounded, does it make sense to just consolidate to one? |
||
.action((dir: String, s: Settings) => s.copy(dataDirPath = dir)) | ||
.text("Scoverage data file directory to be used in case report needed for single measurements " + | ||
"directory. Must set `loadDataDir` to use this options.") | ||
|
||
opt[Unit]("writeHtmlReport") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I think the general usage of |
||
.action((_, s: Settings) => s.copy(writeHtmlReport = true)) | ||
.text("Write the HTML version of the coverage report.") | ||
|
||
opt[Unit]("writeXmlReport") | ||
.action((_, s: Settings) => s.copy(writeXmlReport = true)) | ||
.text("Write the XML version of the coverage report.") | ||
|
||
opt[Unit]("writeXmlDebug") | ||
.action((_, s: Settings) => s.copy(writeXmlDebug = true)) | ||
.text("Write debug information to the XML version of the coverage report.") | ||
|
||
opt[Unit]("cleanOldReports") | ||
.action((_, s: Settings) => s.copy(cleanOldReports = true)) | ||
.text("Delete any existing reports directory prior to writing reports.") | ||
|
||
opt[Seq[String]]("targetFilters") | ||
.action((f: Seq[String], s: Settings) => s.copy(targetFilters = f)) | ||
.text("If not specified, reports are generated for directories with coverage instrument files." + | ||
"Else, report is generated only for the directories specified in the list.") | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If dataDirs is null, this will throw an NullPointerException
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels like this method could just be consolidated with aggregatedCoverage
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aggregate
is called only byloadAggregatedCoverage
and I am checking there if thedataDirs
is null or not. If it doesn't exist, it raises a runtime exception.