"""
junitparser is a JUnit/xUnit Result XML Parser. Use it to parse and manipulate
existing Result XML files, or create new JUnit/xUnit result XMLs from scratch.
Reference schema: https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd
This, according to the document, is Apache Ant's JUnit output.
See the documentation for other supported schemas.
"""
import itertools
from copy import deepcopy
from typing import List
try:
from lxml import etree
except ImportError:
from xml.etree import ElementTree as etree
[docs]
def write_xml(obj, filepath=None, pretty=False, to_console=False):
tree = etree.ElementTree(obj._elem)
if filepath is None:
filepath = obj.filepath
if filepath is None:
raise JUnitXmlError("Missing filepath argument.")
if pretty:
from xml.dom.minidom import parseString
text = etree.tostring(obj._elem)
xml = parseString(text) # nosec
content = xml.toprettyxml(encoding="utf-8")
if to_console:
print(content)
else:
with open(filepath, "wb") as xmlfile:
xmlfile.write(content)
else:
if to_console:
print(
etree.tostring(
obj._elem, encoding="utf-8", xml_declaration=True
).decode("utf-8")
)
else:
tree.write(filepath, encoding="utf-8", xml_declaration=True)
[docs]
class JUnitXmlError(Exception):
"""Exception for JUnit XML related errors."""
[docs]
class Attr(object):
"""An attribute for an XML element.
By default they are all string values. To support different value types,
inherit this class and define your own methods.
Also see: :class:`IntAttr`, :class:`FloatAttr`.
"""
def __init__(self, name: str = None):
self.name = name
def __get__(self, instance, cls):
"""Get value from attribute, return ``None`` if attribute doesn't exist."""
return instance._elem.attrib.get(self.name)
def __set__(self, instance, value: str):
"""Sets XML element attribute."""
if value is not None:
instance._elem.attrib[self.name] = str(value)
[docs]
class IntAttr(Attr):
"""An integer attribute for an XML element.
This class is used internally for counting testcases, but you could use
it for any specific purpose.
"""
def __get__(self, instance, cls):
result = super().__get__(instance, cls)
if result is None and isinstance(instance, (JUnitXml, TestSuite)):
instance.update_statistics()
result = super().__get__(instance, cls)
return int(result) if result else None
def __set__(self, instance, value: int):
if not isinstance(value, int):
raise TypeError("Expected integer value.")
super().__set__(instance, value)
[docs]
class FloatAttr(Attr):
"""A float attribute for an XML element.
This class is used internally for counting test durations, but you could
use it for any specific purpose.
"""
def __get__(self, instance, cls):
result = super().__get__(instance, cls)
if result is None and isinstance(instance, (JUnitXml, TestSuite)):
instance.update_statistics()
result = super().__get__(instance, cls)
return float(result.replace(",", "")) if result else None
def __set__(self, instance, value: float):
if not (isinstance(value, float) or isinstance(value, int)):
raise TypeError("Expected float value.")
super().__set__(instance, value)
[docs]
def attributed(cls):
"""Decorator to read XML element attribute name from class attribute."""
for key, value in vars(cls).items():
if isinstance(value, Attr):
value.name = key
return cls
[docs]
class junitxml(type):
"""Metaclass to decorate the XML class."""
def __new__(meta, name, bases, methods):
cls = super(junitxml, meta).__new__(meta, name, bases, methods)
cls = attributed(cls)
return cls
[docs]
class Element(metaclass=junitxml):
"""Base class for all JUnit XML elements."""
def __init__(self, name: str = None):
if not name:
name = self._tag
self._elem = etree.Element(name)
def __hash__(self):
return hash(etree.tostring(self._elem))
def __repr__(self):
tag = self._elem.tag
keys = sorted(self._elem.attrib.keys())
if keys:
attrs_str = " ".join(
'%s="%s"' % (key, self._elem.attrib[key]) for key in keys
)
return """<Element '%s' %s>""" % (tag, attrs_str)
return """<Element '%s'>""" % tag
[docs]
def append(self, sub_elem):
"""Add the element subelement to the end of this elements internal
list of subelements.
"""
self._elem.append(sub_elem._elem)
[docs]
def extend(self, sub_elems):
"""Add elements subelement to the end of this elements internal
list of subelements.
"""
self._elem.extend((sub_elem._elem for sub_elem in sub_elems))
[docs]
@classmethod
def fromstring(cls, text: str):
"""Construct JUnit object *cls* from XML string *test*."""
instance = cls()
instance._elem = etree.fromstring(text) # nosec
return instance
[docs]
@classmethod
def fromelem(cls, elem):
"""Construct JUnit objects from an ElementTree element *elem*."""
if elem is None:
return
instance = cls()
if isinstance(elem, Element):
instance._elem = elem._elem
else:
instance._elem = elem
return instance
[docs]
def iterchildren(self, Child):
"""Iterate through specified *Child* type elements."""
elems = self._elem.iterfind(Child._tag)
for elem in elems:
yield Child.fromelem(elem)
[docs]
def child(self, Child):
"""Find a single child of specified *Child* type."""
elem = self._elem.find(Child._tag)
return Child.fromelem(elem)
[docs]
def remove(self, sub_elem):
"""Remove subelement *sub_elem*."""
for elem in self._elem.iterfind(sub_elem._tag):
child = sub_elem.__class__.fromelem(elem)
if child == sub_elem:
self._elem.remove(child._elem)
[docs]
def tostring(self):
"""Convert element to XML string."""
return etree.tostring(self._elem, encoding="utf-8")
[docs]
class Result(Element):
"""Base class for test result.
Attributes:
message: Result as message string.
type: Message type.
"""
_tag = None
message = Attr()
type = Attr()
def __init__(self, message: str = None, type_: str = None):
super(Result, self).__init__(self._tag)
if message:
self.message = message
if type_:
self.type = type_
def __eq__(self, other):
return (
self._tag == other._tag
and self.type == other.type
and self.message == other.message
)
@property
def text(self):
return self._elem.text
@text.setter
def text(self, value: str):
self._elem.text = value
[docs]
class Skipped(Result):
"""Test result when the case is skipped."""
_tag = "skipped"
def __eq__(self, other):
return super().__eq__(other)
[docs]
class Failure(Result):
"""Test result when the case failed."""
_tag = "failure"
def __eq__(self, other):
return super().__eq__(other)
[docs]
class Error(Result):
"""Test result when the case has errors during execution."""
_tag = "error"
def __eq__(self, other):
return super().__eq__(other)
POSSIBLE_RESULTS = {Failure, Error, Skipped}
[docs]
class System(Element):
"""Parent class for :class:`SystemOut` and :class:`SystemErr`.
Attributes:
text: The output message.
"""
_tag = ""
def __init__(self, content: str = None):
super().__init__(self._tag)
self.text = content
@property
def text(self):
return self._elem.text
@text.setter
def text(self, value: str):
self._elem.text = value
[docs]
class SystemOut(System):
_tag = "system-out"
[docs]
class SystemErr(System):
_tag = "system-err"
[docs]
class TestCase(Element):
"""Object to store a testcase and its result.
Attributes:
name: Name of the testcase.
classname: The parent class of the testcase.
time: The time consumed by the testcase.
"""
_tag = "testcase"
name = Attr()
classname = Attr()
time = FloatAttr()
__test__ = False
def __init__(self, name: str = None, classname: str = None, time: float = None):
super().__init__(self._tag)
if name is not None:
self.name = name
if classname is not None:
self.classname = classname
if time is not None:
self.time = float(time)
def __hash__(self):
return super().__hash__()
def __iter__(self):
all_types = set.union(POSSIBLE_RESULTS, {SystemOut}, {SystemErr})
for elem in self._elem.iter():
for entry_type in all_types:
if elem.tag == entry_type._tag:
yield entry_type.fromelem(elem)
def __eq__(self, other):
# TODO: May not work correctly if unreliable hash method is used.
return hash(self) == hash(other)
@property
def is_passed(self):
"""Whether this testcase was a success (i.e. if it isn't skipped, failed, or errored)."""
return not self.result
@property
def is_skipped(self):
"""Whether this testcase was skipped."""
for r in self.result:
if isinstance(r, Skipped):
return True
return False
@property
def result(self):
"""A list of :class:`Failure`, :class:`Skipped`, or :class:`Error` objects."""
results = []
for entry in self:
if isinstance(entry, tuple(POSSIBLE_RESULTS)):
results.append(entry)
return results
@result.setter
def result(self, value: Result):
# First remove all existing results
for entry in self.result:
if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
self.remove(entry)
for entry in value:
if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
self.append(entry)
@property
def system_out(self):
"""stdout."""
elem = self.child(SystemOut)
if elem is not None:
return elem.text
return None
@system_out.setter
def system_out(self, value: str):
out = self.child(SystemOut)
if out is not None:
out.text = value
else:
out = SystemOut(value)
self.append(out)
@property
def system_err(self):
"""stderr."""
elem = self.child(SystemErr)
if elem is not None:
return elem.text
return None
@system_err.setter
def system_err(self, value: str):
err = self.child(SystemErr)
if err is not None:
err.text = value
else:
err = SystemErr(value)
self.append(err)
[docs]
class Property(Element):
"""A key/value pare that's stored in the testsuite.
Use it to store anything you find interesting or useful.
Attributes:
name: The property name.
value: The property value.
"""
_tag = "property"
name = Attr()
value = Attr()
def __init__(self, name: str = None, value: str = None):
super().__init__(self._tag)
self.name = name
self.value = value
def __eq__(self, other):
return self.name == other.name and self.value == other.value
def __ne__(self, other):
return not self == other
def __lt__(self, other):
"""Supports sort() for properties."""
return self.name > other.name
[docs]
class Properties(Element):
"""A list of properties inside a testsuite.
See :class:`Property`
"""
_tag = "properties"
def __init__(self):
super().__init__(self._tag)
[docs]
def add_property(self, property_: Property):
self.append(property_)
def __iter__(self):
return super().iterchildren(Property)
def __eq__(self, other):
p1 = list(self)
p2 = list(other)
p1.sort()
p2.sort()
if len(p1) != len(p2):
return False
for e1, e2 in zip(p1, p2):
if e1 != e2:
return False
return True
[docs]
class TestSuite(Element):
"""The <testsuite> object.
Attributes:
name: The name of the testsuite.
hostname: Name of the test machine.
time: Time consumed by the testsuite.
timestamp: When the test was run.
tests: Total number of tests.
failures: Number of failed tests.
errors: Number of cases with errors.
skipped: Number of skipped cases.
"""
_tag = "testsuite"
name = Attr()
hostname = Attr()
time = FloatAttr()
timestamp = Attr()
tests = IntAttr()
failures = IntAttr()
errors = IntAttr()
skipped = IntAttr()
__test__ = False
def __init__(self, name=None):
super().__init__(self._tag)
self.name = name
self.filepath = None
def __iter__(self):
return itertools.chain(
super().iterchildren(TestCase),
(case for suite in super().iterchildren(TestSuite) for case in suite),
)
def __len__(self):
return len(list(self.__iter__()))
def __eq__(self, other):
def props_eq(props1, props2):
props1 = list(props1)
props2 = list(props2)
if len(props1) != len(props2):
return False
props1.sort(key=lambda x: x.name)
props2.sort(key=lambda x: x.name)
zipped = zip(props1, props2)
return all(x == y for x, y in zipped)
return (
self.name == other.name
and self.hostname == other.hostname
and self.timestamp == other.timestamp
) and props_eq(self.properties(), other.properties())
def __add__(self, other):
if self == other:
# Merge the two testsuites
result = deepcopy(self)
for case in other:
result._add_testcase_no_update_stats(case)
for suite in other.testsuites():
result.add_testsuite(suite)
result.update_statistics()
else:
# Create a new test result containing two testsuites
result = JUnitXml()
result.add_testsuite(self)
result.add_testsuite(other)
return result
def __iadd__(self, other):
if self == other:
for case in other:
self._add_testcase_no_update_stats(case)
for suite in other.testsuites():
self.add_testsuite(suite)
self.update_statistics()
return self
result = JUnitXml()
result.filepath = self.filepath
result.add_testsuite(self)
result.add_testsuite(other)
return result
[docs]
def remove_testcase(self, testcase: TestCase):
"""Remove testcase *testcase* from the testsuite."""
for case in self:
if case == testcase:
super().remove(case)
self.update_statistics()
[docs]
def update_statistics(self):
"""Update test count and test time."""
tests = errors = failures = skipped = 0
time = 0
for case in self:
tests += 1
if case.time is not None:
time += case.time
for entry in case.result:
if isinstance(entry, Failure):
failures += 1
elif isinstance(entry, Error):
errors += 1
elif isinstance(entry, Skipped):
skipped += 1
self.tests = tests
self.errors = errors
self.failures = failures
self.skipped = skipped
self.time = round(time, 3)
[docs]
def add_property(self, name: str, value: str):
"""Add a property *name* = *value* to the testsuite.
See :class:`Property` and :class:`Properties`.
"""
props = self.child(Properties)
if props is None:
props = Properties()
self.append(props)
prop = Property(name, value)
props.add_property(prop)
[docs]
def add_testcase(self, testcase: TestCase):
"""Add a testcase *testcase* to the testsuite."""
self.append(testcase)
self.update_statistics()
[docs]
def add_testcases(self, testcases: List[TestCase]):
"""Add testcases *testcases* to the testsuite."""
self.extend(testcases)
self.update_statistics()
def _add_testcase_no_update_stats(self, testcase: TestCase):
"""Add *testcase* to the testsuite (without updating statistics).
For internal use only to avoid quadratic behaviour in merge.
"""
self.append(testcase)
[docs]
def add_testsuite(self, suite):
"""Add a testsuite *suite* to the testsuite."""
self.append(suite)
[docs]
def properties(self):
"""Iterate through all :class:`Property` elements in the testsuite."""
props = self.child(Properties)
if props is None:
return
for prop in props:
yield prop
[docs]
def remove_property(self, property_: Property):
"""Remove property *property_* from the testsuite."""
props = self.child(Properties)
if props is None:
return
for prop in props:
if prop == property_:
props.remove(property_)
[docs]
def testsuites(self):
"""Iterate through all testsuites."""
for suite in self.iterchildren(TestSuite):
yield suite
[docs]
def write(self, filepath: str = None, pretty=False):
write_xml(self, filepath=filepath, pretty=pretty)
[docs]
class JUnitXml(Element):
"""The JUnitXml root object.
It may contain ``<TestSuites>`` or a ``<TestSuite>``.
Attributes:
name: Name of the testsuite if it only contains one testsuite.
time: Time consumed by the testsuites.
tests: Total number of tests.
failures: Number of failed cases.
errors: Number of cases with errors.
skipped: Number of skipped cases.
"""
_tag = "testsuites"
name = Attr()
time = FloatAttr()
tests = IntAttr()
failures = IntAttr()
errors = IntAttr()
skipped = IntAttr()
def __init__(self, name=None):
super().__init__(self._tag)
self.filepath = None
self.name = name
def __iter__(self):
return super().iterchildren(TestSuite)
def __len__(self):
return len(list(self.__iter__()))
def __add__(self, other):
result = JUnitXml()
for suite in self:
result.add_testsuite(suite)
for suite in other:
result.add_testsuite(suite)
return result
def __iadd__(self, other):
if other._elem.tag == "testsuites":
for suite in other:
self.add_testsuite(suite)
elif other._elem.tag == "testsuite":
suite = TestSuite(name=other.name)
for case in other:
suite._add_testcase_no_update_stats(case)
self.add_testsuite(suite)
self.update_statistics()
return self
[docs]
def add_testsuite(self, suite: TestSuite):
"""Add a testsuite."""
for existing_suite in self:
if existing_suite == suite:
for case in suite:
existing_suite._add_testcase_no_update_stats(case)
return
self.append(suite)
[docs]
def update_statistics(self):
"""Update test count, time, etc."""
time = 0
tests = failures = errors = skipped = 0
for suite in self:
suite.update_statistics()
tests += suite.tests
failures += suite.failures
errors += suite.errors
skipped += suite.skipped
time += suite.time
self.tests = tests
self.failures = failures
self.errors = errors
self.skipped = skipped
self.time = round(time, 3)
[docs]
@classmethod
def fromroot(cls, root_elem: Element):
"""Construct JUnit objects from an elementTree root element."""
if root_elem.tag == "testsuites":
instance = cls()
elif root_elem.tag == "testsuite":
instance = TestSuite()
else:
raise JUnitXmlError("Invalid format.")
instance._elem = root_elem
return instance
[docs]
@classmethod
def fromstring(cls, text: str):
"""Construct JUnit objects from an XML string."""
root_elem = etree.fromstring(text) # nosec
return cls.fromroot(root_elem)
[docs]
@classmethod
def fromfile(cls, filepath: str, parse_func=None):
"""Initiate the object from a report file."""
if parse_func:
tree = parse_func(filepath)
else:
tree = etree.parse(filepath) # nosec
root_elem = tree.getroot()
instance = cls.fromroot(root_elem)
instance.filepath = filepath
return instance
[docs]
def write(self, filepath: str = None, pretty=False, to_console=False):
"""Write the object into a JUnit XML file.
If `file_path` is not specified, it will write to the original file.
If `pretty` is True, the result file will be more human friendly.
"""
write_xml(self, filepath=filepath, pretty=pretty, to_console=to_console)