Source code for pysysjava.coverage

"""
Writer that collects and reports on Java code coverage, using the JaCoCo library. 

"""

__all__ = [
	"JavaCoverageWriter",
]

import logging, sys, io, os, shlex, glob, time

from pysys.constants import *
from pysys.writer.api import *
from pysys.writer.testoutput import CollectTestOutputWriter
from pysys.utils.fileutils import mkdir, deletedir, toLongPathSafe, fromLongPathSafe, pathexists
import pysysjava

log = logging.getLogger('pysys.pysysjava.coverage')

def safeGlob(globPattern, expected='>=1', name='Glob pattern'):
	if not globPattern:
		result = []
	else:
		assert os.path.isabs(globPattern), globPattern
		result = glob.glob(os.path.normpath(globPattern), recursive=True) # todo: add longpathsafe
	if eval(str(len(result))+expected): 
		if expected.replace(' ','')=='==1': return result[0]
		return result
	raise FileNotFoundError('%s should return %s result(s) but got %d: "%s"'%(name, expected, len(result), globPattern))

[docs]class JavaCoverageWriter(CollectTestOutputWriter): """Writer that collects that coverage data in a single directory and writes coverage XML and HTML reports during runner cleanup. Requires the "JaCoCo" code coverage library. To enable this, run with ``-XcodeCoverage`` (or ``-XjavaCoverage``) and configure the ``destDir`` plugin property in pysysproject.xml (e.g. to ``__coverage_java.${outDirName}``). When enabled, the `getCoverageJVMArgs` method can be used to generate the JVM arguments needed to collect coverage data (for example, `pysysjava.javaplugin.JavaPlugin.startJava` calls this method). You must configure the writer with the alias ``javaCoverageWriter`` so that the plugin knows to use it when starting Java processes. Note that this writer uses ``output=file`` JaCoCo agent option which dumps coverage information to a file when the Java process exits. This means background processes that are still running when the PySys test finishes (and get killed by PySys) will not generate any coverage - so try to ensure clean termination where possible. (An alternative approach would be to implement a Java TCP server using the JaCoCo API which each Java process connects to, but this writer doesn't go that far). If coverage is generated, the directory containing all coverage files is published as an artifact named "coverageDestDir". Optionally an archive of this directory can be generated by setting the ``destArchive`` property (see `pysys.writer.testoutput.CollectTestOutputWriter`), and published as "JavaCoverageArchive". The following properties can be set in the project configuration for this writer: """ # override CollectTestOutputWriter property values destDir = u'' fileIncludesRegex = u'.*[.]javacoverage' # executed against the path relative to the test root dir e.g. (pattern1|pattern2) outputPattern = u'@TESTID@_@FILENAME@.@UNIQUE@.@FILENAME_EXT@' publishArtifactDirCategory = u'JavaCoverageDir' publishArtifactArchiveCategory = u'JavaCoverageArchive' jacocoDir = '' """ This mandatory writer property configures the directory containing the JaCoCo jar files ``org.jacoco.agent*.jar`` and ``org.jacoco.cli*.jar``. """ agentArgs = '' """ A comma-separated string of additional agent parameters to pass to the JaCoCo agent to control gathering of coverage data. For example "includes=myorg.myserver.*,excludes=myorg.myserver.tests.*" can be used to include/exclude packages from coverage data. See the JaCoCo documentation for more information about the Java Agent. """ classpath = '' """ The classpath containing the application classes which should be included in the code coverage report. If this property is not set then no HTML or XML report will be generated. Glob ``*`` expressions can be used. For full details of how this plugin handles the classpath string see `pysysjava.javaplugin.JavaPlugin.toClasspathList()`. """ sourceDirs = '' """ A semi-colon separated list of directories containing the ``.java`` source files used to compile the classpath classes. This is required for the HTML report to show the line-by-line source file coverage. """ reportArgs = '' """ A space-separated string of additional command line arguments to pass to the JaCoCo report command line. For example "--encoding utf-8". """ def isEnabled(self, record=False, **kwargs): enabled = (self.runner.getBoolProperty('javaCoverage', default=self.runner.getBoolProperty('codeCoverage'))) if not enabled: self.__agentJar = None return if not self.destDir: raise Exception('The destDir JavaCoverageWriter property must be set') if (not self.jacocoDir) or (not os.path.isdir(self.jacocoDir)): raise Exception('The jacocoDir JavaCoverageWriter property must be set and must exist: "%s"'%self.jacocoDir) self.__agentJar = safeGlob(self.jacocoDir+'/*jacoco*agent*.jar', expected='==1', name='JaCoCo agent jar (from the jacocoDir)').replace('\\','/') return True
[docs] def getCoverageJVMArgs(self, owner, stdouterr=None): """ Get the JVM arguments needed to add Java coverage to a new Java process, or empty if this coverage writer is not currently enabled. This is called by `pysysjava.javaplugin.JavaPlugin.startJava` (and any custom methods that start JVMs) to add coverage collection. :param pysys.basetest.BaseTest owner: The BaseTest/BaseRunner owner of the process that is to be started. This is used to keep track of coverage names already allocated, and str(owner) is used as sessionId metadata. :param str stdouterr: The name of the stdouterr for the process being measured (or None if not available), used to contribute to the coverage filename. """ if self.__agentJar is None: return [] sessionid = str(owner) # pick a unique name for the coverage file destfile = 'jacoco' if stdouterr and isinstance(stdouterr, str): sessionid += '.'+os.path.basename(stdouterr) destfile += '-'+os.path.basename(stdouterr) destfile += '%s.javacoverage' namesUsed = getattr(owner, '__JavaCoverageWriter.coverageFileNamesUsed', set()) setattr(owner, '__JavaCoverageWriter.coverageFileNamesUsed', namesUsed) uniquer, n = '', 1 while destfile % uniquer in namesUsed: n, uniquer = n+1, '.%s'%n agentArgs = self.agentArgs if agentArgs: agentArgs = ','+agentArgs if 'output=' not in agentArgs: agentArgs = f',output=file,destfile={destfile % uniquer}{agentArgs}' return [f'-javaagent:{self.__agentJar}=sessionid={sessionid}{agentArgs}']
def cleanup(self, **kwargs): java = pysysjava.javaplugin.JavaPlugin() java.setup(self.runner) coverageDestDir = self.destDir assert os.path.isabs(coverageDestDir) # The base class is responsible for absolutizing this config property coverageDestDir = os.path.normpath(fromLongPathSafe(coverageDestDir)) if not pathexists(coverageDestDir): log.info('No Java coverage files were generated.') return log.info('Preparing Java coverage report in: %s', coverageDestDir) cliJar = safeGlob(self.jacocoDir+'/*jacoco*cli*.jar', expected='==1', name='JaCoCo CLI jar (from the jacocoDir)') coveragefiles = [f for f in os.listdir(coverageDestDir) if f.endswith('.javacoverage')] java.startJava(cliJar, ['merge']+coveragefiles+['--destfile', 'jacoco-merged-java-coverage.exec'], abortOnError=True, workingDir=coverageDestDir, stdouterr=coverageDestDir+'/java-coverage-merge', disableCoverage=True, onError=lambda process: 'Failed to merge Java code coverage data: %s'%self.runner.getExprFromFile(process.stderr, '.+', returnAll=True)[-1] or self.runner.logFileContents(process.stderr, maxLines=0)) for f in coveragefiles: os.remove(toLongPathSafe(coverageDestDir+os.sep+f)) classpath = java.toClasspathList(self.classpath) if not classpath: log.info('No Java report will be generated as no classpath was specified') else: log.debug('Application classpath for the coverage report is: \n%s', '\n'.join(" cp #%-2d : %s%s"%( i+1, pathelement, '' if os.path.exists(pathelement) else ' (does not exist!)') for i, pathelement in enumerate(classpath))) sourceDirs = java.toClasspathList(self.sourceDirs) # not really a classpath, but or consistency, parse it the same way args = [] for x in classpath: args.extend(['--classfiles', x]) for x in sourceDirs: args.extend(['--sourcefiles', x]) if sourceDirs: (log.warn if any(not os.path.exists(p) for p in sourceDirs) else log.debug)('Java source directories for the coverage report are: \n%s', '\n'.join(" dir #%-2d : %s%s"%( i+1, pathelement, '' if os.path.exists(pathelement) else ' (does not exist!)') for i, pathelement in enumerate(sourceDirs))) else: log.info('No source directories were provided so the coverage HTML report will not include line-by-line highlighted source files') java.startJava(cliJar, ['report', 'jacoco-merged-java-coverage.exec', '--xml', 'java-coverage.xml', '--html', '.'] +java._splitShellArgs(self.reportArgs)+args, abortOnError=True, workingDir=coverageDestDir, stdouterr=coverageDestDir+'/java-coverage-report', disableCoverage=True, onError=lambda process: 'Failed to create Java code coverage report: %s'%self.runner.getExprFromFile(process.stderr, '.+', returnAll=True)[-1] or self.runner.logFileContents(process.stderr, maxLines=0)) # to avoid confusion, remove any zero byte out/err files from the above for p in os.listdir(coverageDestDir): p = os.path.join(coverageDestDir, p) if p.endswith(('.out', '.err')) and os.path.getsize(p)==0: os.remove(p) try: self.archiveAndPublish() except PermissionError: # pragma: no cover - can occur transiently on Windows due to file system locking time.sleep(5.0) self.archiveAndPublish()