src/shoobx/junitxml/formatter.py
###############################################################################
#
# Copyright 2017 by Plone Foundation and Shoobx, Inc.
#
###############################################################################
"""Shoobx JUnit XML Output Formatter
"""
import codecs
import datetime
import doctest
import lxml.etree
import os
import os.path
import six
import socket
import traceback
from zope.testrunner.find import StartUpFailure
try:
import manuel.testing
HAVE_MANUEL = True
except ImportError:
HAVE_MANUEL = False
PRECISION = 5
STRFMT = "%." + str(PRECISION) + "f"
class TestSuiteInfo(object):
def __init__(self):
self.testCases = []
self.errors = 0
self.failures = 0
self.time = 0.0
@property
def tests(self):
return len(self.testCases)
@property
def successes(self):
return self.tests - (self.errors + self.failures)
class TestCaseInfo(object):
def __init__(self, test, time, testClassName, testName, failure=None,
error=None, extraData=None):
self.test = test
self.time = time
self.testClassName = testClassName
self.testName = testName
self.failure = failure
self.error = error
self.extraData = extraData
def round_str(number):
return STRFMT % round(number, PRECISION)
def get_test_class_name(test):
"""Compute the test class name from the test object."""
return "%s.%s" % (test.__module__, test.__class__.__name__, )
def filename_to_suite_name_parts(filename):
# lop off whatever portion of the path we have in common
# with the current working directory; crude, but about as
# much as we can do :(
filenameParts = filename.split(os.path.sep)
cwdParts = os.getcwd().split(os.path.sep)
longest = min(len(filenameParts), len(cwdParts))
for i in range(longest):
if filenameParts[i] != cwdParts[i]:
break
if i < len(filenameParts) - 1:
# The real package name couldn't have a '.' in it. This
# makes sense for the common egg naming patterns, and
# will still work in other cases
# most zope packages source is in 'src', stop there
suiteNameParts = []
for part in reversed(filenameParts[i:-1]):
if part == 'src' or '.' in part:
break
suiteNameParts.insert(0, part)
# don't lose the filename, which would have a . in it
suiteNameParts.append(filenameParts[-1])
return suiteNameParts
def parse_layer(test):
if isinstance(test, six.string_types):
parts = test.split('.')
klass = '.'.join(parts[:-1])
return parts[-1], klass
return None, None
def parse_doc_file_case(test):
if not isinstance(test, doctest.DocFileCase):
return None, None
filename = test._dt_test.filename
suiteNameParts = filename_to_suite_name_parts(filename)
testName = test._dt_test.name
testClassName = '.'.join(suiteNameParts[:-1])
return testName, testClassName
def parse_doc_test_case(test):
if not isinstance(test, doctest.DocTestCase):
return None, None
testDottedNameParts = test._dt_test.name.split('.')
testName = testDottedNameParts[-1]
# Record test class as module.where.test.is.defined.DocTestCase
testClassName = '.'.join(
testDottedNameParts[:-1] +
[test.__class__.__name__])
return testName, testClassName
def parse_manuel(test):
if not (HAVE_MANUEL and isinstance(test, manuel.testing.TestCase)):
return None, None
filename = test.regions.location
suiteNameParts = filename_to_suite_name_parts(filename)
testName = suiteNameParts[-1]
testClassName = '.'.join(suiteNameParts[:-1])
return testName, testClassName
def parse_startup_failure(test):
if not isinstance(test, StartUpFailure):
return None, None
testModuleName = test.module
return 'Startup', testModuleName
def parse_unittest(test):
testId = test.id()
if testId is None:
return None, None
testClassName = get_test_class_name(test)
testName = testId[len(testClassName)+1:]
return testName, testClassName
def addNodeText(node, text):
"""Appends text to an XML node.
If text contains binary data that are not supported by lxml,
sets error message and base64 encoded representation of the text.
"""
try:
node.text = (node.text or "") + text
return
except ValueError:
pass
if not isinstance(text, six.binary_type):
text = text.encode('utf-8')
node.text = (
(node.text and node.text + "\n" or "") +
"Text contains characters illegal in XML.\n" +
"Here's Base64 representation:\n" +
"="*60 + "\n" +
codecs.encode(text, 'base64').decode('utf-8') +
"="*60)
class XMLOutputFormattingWrapper(object):
"""Output formatter which delegates to another formatter for all
operations, but also prepares an element tree of test output.
"""
def __init__(self, delegate, outputPath, outputSetupTeardown=True):
self.delegate = delegate
self.testSuite = TestSuiteInfo()
self.outputPath = outputPath
self.outputSetupTeardown = outputSetupTeardown
def __getattr__(self, name):
return getattr(self.delegate, name)
def test_failure(self, test, seconds, exc_info, stdout=None, stderr=None):
self._record(test, seconds, failure=exc_info)
return self.delegate.test_failure(test, seconds, exc_info,
stdout=stdout, stderr=stderr)
def test_error(self, test, seconds, exc_info, stdout=None, stderr=None):
self._record(test, seconds, error=exc_info)
return self.delegate.test_error(test, seconds, exc_info,
stdout=stdout, stderr=stderr)
def test_success(self, test, seconds):
self._record(test, seconds)
return self.delegate.test_success(test, seconds)
def test_skipped(self, test, reason):
self._record(test, 0, extraData=dict(skipped=reason))
return self.delegate.test_skipped(test, reason)
def import_errors(self, import_errors):
if import_errors:
for test in import_errors:
self._record(test, 0, error=test.exc_info)
return self.delegate.import_errors(import_errors)
def start_set_up(self, layer_name):
"""Report that we're setting up a layer."""
self._last_layer = layer_name
return self.delegate.start_set_up(layer_name)
def stop_set_up(self, seconds):
layer_name = self._last_layer
self._last_layer = None
if self.outputSetupTeardown:
self._record('%s:setUp' % (layer_name,), seconds)
return self.delegate.stop_set_up(seconds)
def start_tear_down(self, layer_name):
"""Report that we're tearing down a layer."""
self._last_layer = layer_name
return self.delegate.start_tear_down(layer_name)
def stop_tear_down(self, seconds):
layer_name = self._last_layer
self._last_layer = None
if self.outputSetupTeardown:
self._record('%s:tearDown' % (layer_name,), seconds)
return self.delegate.stop_tear_down(seconds)
def tear_down_not_supported(self):
"""Report that we could not tear down a layer."""
layer_name = self._last_layer
self._last_layer = None
self._record('%s:tearDown' % (layer_name,), 0,
extraData=dict(skipped=u'Not supported'))
return self.delegate.tear_down_not_supported()
def _record(self, test, seconds, failure=None, error=None,
extraData=None):
for parser in [parse_layer,
parse_doc_file_case,
parse_doc_test_case,
parse_manuel,
parse_startup_failure,
parse_unittest]:
testName, testClassName = parser(test)
if (testName, testClassName) != (None, None):
break
if (testName, testClassName) == (None, None):
raise TypeError(
"Unknown test type: Could not compute testName, "
"testClassName: %r" % test)
self.testSuite.testCases.append(
TestCaseInfo(
test, seconds, testClassName, testName,
failure, error, extraData))
if failure is not None:
self.testSuite.failures += 1
if error is not None:
self.testSuite.errors += 1
if seconds:
self.testSuite.time += seconds
def writeXMLReports(self, properties=None):
testSuiteNode = lxml.etree.Element(
'testsuite',
tests=str(self.testSuite.tests),
errors=str(self.testSuite.errors),
failures=str(self.testSuite.failures),
hostname=socket.gethostname(),
name='',
time=round_str(self.testSuite.time),
timestamp=datetime.datetime.now().isoformat()
)
propertiesNode = lxml.etree.SubElement(testSuiteNode, 'properties')
for k, v in (properties or {}).items():
lxml.etree.SubElement(
propertyNode, 'property', name=k, value=v)
for testCase in self.testSuite.testCases:
testCaseNode = lxml.etree.SubElement(
testSuiteNode, 'testcase',
classname=testCase.testClassName,
name=testCase.testName,
time=round_str(testCase.time))
if testCase.error:
errorNode = lxml.etree.SubElement(testCaseNode, 'error')
try:
excType, excInstance, tb = testCase.error
errorMessage = str(excInstance)
stackTrace = ''.join(traceback.format_tb(tb))
finally: # Avoids a memory leak
del tb
errorNode.attrib.update({
'message': errorMessage.split('\n')[0],
'type': str(excType)
})
addNodeText(errorNode, errorMessage)
if errorNode.text:
errorNode.text = errorNode.text + '\n\n'
addNodeText(errorNode, stackTrace)
if testCase.failure:
failureNode = lxml.etree.SubElement(testCaseNode, 'failure')
try:
excType, excInstance, tb = testCase.failure
errorMessage = str(excInstance)
stackTrace = ''.join(traceback.format_tb(tb))
except UnicodeEncodeError:
errorMessage = \
'Could not extract error str for unicode error'
stackTrace = ''.join(traceback.format_tb(tb))
finally: # Avoids a memory leak
del tb
failureNode.attrib.update({
'message': errorMessage.split('\n')[0],
'type': str(excType)
})
addNodeText(failureNode, errorMessage)
if failureNode.text:
failureNode.text = failureNode.text + '\n\n'
addNodeText(failureNode, stackTrace)
if testCase.extraData is not None:
for key, val in testCase.extraData.items():
newNode = lxml.etree.SubElement(testCaseNode, key)
addNodeText(newNode, val)
# XXX: We don't have a good way to capture these yet
systemOutNode = lxml.etree.SubElement(testSuiteNode, 'system-out')
systemErrNode = lxml.etree.SubElement(testSuiteNode, 'system-err')
# Write file
with open(self.outputPath, 'wb') as file:
file.write(lxml.etree.tostring(testSuiteNode, pretty_print=True))