""" Parse a junit report file into a family of objects """ from __future__ import unicode_literals from typing import TYPE_CHECKING import os import sys import xml.etree.ElementTree as ET import collections import uuid from .case_result import CaseResult from .render import HTMLReport from .textutils import unicode_str if TYPE_CHECKING: from typing import Dict, List, Optional, Union, Any, OrderedDict NO_CLASSNAME = "no-testclass" PASSED = CaseResult.PASSED FAILED = CaseResult.FAILED SKIPPED = CaseResult.SKIPPED ABSENT = CaseResult.ABSENT UNKNOWN = CaseResult.UNKNOWN def clean_xml_attribute(element: "ET.Element", attribute: str, default: "Optional[str]"=None): """ Get an XML attribute value and ensure it is legal in XML :param element: :param attribute: :param default: :return: """ value = element.attrib.get(attribute, default) if value: value = value.encode("utf-8", errors="replace").decode("utf-8", errors="backslashreplace") value = value.replace(u"\ufffd", "?") # strip out the unicode replacement char return value class ParserError(Exception): """ We had a problem parsing a file """ def __init__(self, message: str): super(ParserError, self).__init__(message) class ToJunitXmlBase(object): """ Base class of all objects that can be serialized to Junit XML """ def tojunit(self) -> "ET.Element": """ Return an Element matching this object :return: """ raise NotImplementedError() def make_element(self, xmltag: str, text: "Optional[str]"=None, attribs: "Optional[Dict[str, Any]]"=None): """ Create an Element and put text and/or attribs into it :param xmltag: tag name :param text: :param attribs: dict of xml attributes :return: """ element = ET.Element(unicode_str(xmltag)) if text is not None: element.text = unicode_str(text) if attribs is not None: for item in attribs: element.set(unicode_str(item), unicode_str(attribs[item])) return element class AnchorBase(object): """ Base class that can generate a unique anchor name. """ def __init__(self): self._anchor = None def id(self): return self.anchor() def anchor(self): """ Generate a html anchor name :return: """ if not self._anchor: self._anchor = str(uuid.uuid4()) return self._anchor class Class(AnchorBase): """ A namespace for a test """ name: "Optional[str]" = None cases: "list[Case]" def __init__(self): super(Class, self).__init__() self.cases = list() class Property(AnchorBase, ToJunitXmlBase): """ Test Properties """ def __init__(self): super(Property, self).__init__() self.name: "Optional[str]" = None self.value: "Optional[str]" = None def tojunit(self): """ Return the xml element for this property :return: """ prop = self.make_element("property") prop.set(u"name", unicode_str(self.name)) prop.set(u"value", unicode_str(self.value)) return prop class Case(AnchorBase, ToJunitXmlBase): """ Test cases """ failure: "Optional[str]" = None failure_msg: "Optional[str]" = None skipped: "Optional[str]" = None skipped_msg: "Optional[str]" = None stderr: "Optional[Union[str,Any]]" = None stdout: "Optional[Union[str,Any]]" = None duration: float = 0 name: "Optional[str]" = None testclass: "Optional[Class]" = None properties: "List[Property]" def __init__(self): super(Case, self).__init__() self.properties = list() @property def display_suffix(self): if self.skipped: return "[s]" return "" def outcome(self) -> CaseResult: """ Return the result of this test case :return: """ if self.skipped: return CaseResult.SKIPPED elif self.failed(): return CaseResult.FAILED return CaseResult.PASSED def prefix(self): if self.skipped: return "[S]" if self.failed(): return "[F]" return "" def tojunit(self): """ Turn this test case back into junit xml :note: this may not be the exact input we loaded :return: """ if self.testclass is None or self.testclass.name is None: testclass_name = "" else: testclass_name = self.testclass.name testcase = self.make_element("testcase") testcase.set(u"name", unicode_str(self.name)) testcase.set(u"classname", unicode_str(testclass_name)) testcase.set(u"time", unicode_str(self.duration)) if self.stderr is not None: testcase.append(self.make_element("system-err", self.stderr)) if self.stdout is not None: testcase.append(self.make_element("system-out", self.stdout)) if self.failure is not None: testcase.append(self.make_element( "failure", self.failure, { "message": self.failure_msg })) if self.skipped: testcase.append(self.make_element( "skipped", self.skipped, { "message": self.skipped_msg })) if self.properties: props = self.make_element("properties") for prop in self.properties: props.append(prop.tojunit()) testcase.append(props) return testcase def fullname(self): """ Get the full name of a test case :return: """ if self.testclass is None or self.testclass.name is None: testclass_name = "" else: testclass_name = self.testclass.name return "{} : {}".format(testclass_name, self.name) def basename(self): """ Get a short name for this case :return: """ if ( self.name is None or self.testclass is None or self.testclass.name is None ): return None if self.name.startswith(self.testclass.name): return self.name[len(self.testclass.name):] return self.name def failed(self): """ Return True if this test failed :return: """ return self.failure is not None class Suite(AnchorBase, ToJunitXmlBase): """ Contains test cases (usually only one suite per report) """ name: "Optional[str]" = None properties: "List[Property]" classes: "OrderedDict[str, Class]" duration: float = 0 package: "Optional[str]" = None errors: "List[Dict[str, Optional[Union[str,Any]]]]" stdout: "Optional[Union[str,Any]]" = None stderr: "Optional[Union[str,Any]]" = None def __init__(self): super(Suite, self).__init__() self.classes = collections.OrderedDict() self.properties = [] self.errors = [] def tojunit(self): """ Return an element for this whole suite and all it's cases :return: """ suite = self.make_element("testsuite") suite.set(u"name", unicode_str(self.name)) suite.set(u"time", unicode_str(self.duration)) if self.properties: props = self.make_element("properties") for prop in self.properties: props.append(prop.tojunit()) suite.append(props) for testcase in self.all(): suite.append(testcase.tojunit()) return suite def __contains__(self, item: str): """ Return True if the given test classname is part of this test suite :param item: :return: """ return item in self.classes def __getitem__(self, item: str): """ Return the given test class object :param item: :return: """ return self.classes[item] def __setitem__(self, key: str, value: "Class"): """ Add a test class :param key: :param value: :return: """ self.classes[key] = value def all(self): """ Return all testcases :return: """ tests: "List[Case]" = list() for testclass in self.classes: tests.extend(self.classes[testclass].cases) return tests def failed(self): """ Return all the failed testcases :return: """ return [test for test in self.all() if test.failed()] def skipped(self): """ Return all skipped testcases :return: """ return [test for test in self.all() if test.skipped] def passed(self): """ Return all the passing testcases :return: """ return [test for test in self.all() if not test.failed() and not test.skipped] class Junit(object): """ Parse a single junit xml report """ filename: "Optional[str]" suites: "List[Suite]" tree: "Union[ET.ElementTree,ET.Element]" def __init__(self, filename: "Optional[str]"=None, xmlstring: "Optional[str]"=None): """ Parse the file :param filename: :return: """ self.filename = filename if filename == "-": # read the xml from stdin stdin = sys.stdin.read() xmlstring = stdin self.filename = None self.tree = None # type: ignore if self.filename is not None: self.tree = ET.parse(self.filename) elif xmlstring is not None: self._read(xmlstring) else: raise ValueError("Missing any filename or xmlstring") self.suites = [] self.process() def __iter__(self): return self.suites.__iter__() def _read(self, xmlstring: str): """ Populate the junit xml document tree from a string :param xmlstring: :return: """ self.tree = ET.fromstring(xmlstring) def process(self): """ populate the report from the xml :return: """ testrun = False suites: "Optional[list[ET.Element]]" = None root: "ET.Element" if isinstance(self.tree, ET.ElementTree): root = self.tree.getroot() else: root = self.tree if root.tag == "testrun": testrun = True root: "ET.Element" = root[0] if root.tag == "testsuite": suites = [root] if root.tag == "testsuites" or testrun: suites = [x for x in root] if suites is None: raise ParserError("could not find test suites in results xml") suitecount = 0 for suite in suites: suitecount += 1 cursuite = Suite() self.suites.append(cursuite) cursuite.name = clean_xml_attribute(suite, "name", default="suite-" + str(suitecount)) cursuite.package = clean_xml_attribute(suite, "package") cursuite.duration = float(suite.attrib.get("time", '0').replace(',', '') or '0') for element in suite: if element.tag == "error": # top level error? errtag = { "message": element.attrib.get("message", ""), "type": element.attrib.get("type", ""), "text": element.text } cursuite.errors.append(errtag) if element.tag == "system-out": cursuite.stdout = element.text if element.tag == "system-err": cursuite.stderr = element.text if element.tag == "properties": for prop in element: if prop.tag == "property": newproperty = Property() newproperty.name = prop.attrib["name"] newproperty.value = prop.attrib["value"] cursuite.properties.append(newproperty) if element.tag == "testcase": testcase = element if not testcase.attrib.get("classname", None): testcase.attrib["classname"] = NO_CLASSNAME if testcase.attrib["classname"] not in cursuite: testclass = Class() testclass.name = testcase.attrib["classname"] cursuite[testclass.name] = testclass testclass: "Class" = cursuite[testcase.attrib["classname"]] newcase = Case() newcase.name = clean_xml_attribute(testcase, "name") newcase.testclass = testclass newcase.duration = float(testcase.attrib.get("time", '0').replace(',', '') or '0') testclass.cases.append(newcase) # does this test case have any children? for child in testcase: if child.tag == "skipped": newcase.skipped = child.text if "message" in child.attrib: newcase.skipped_msg = child.attrib["message"] if not newcase.skipped: newcase.skipped = "skipped" elif child.tag == "system-out": newcase.stdout = child.text elif child.tag == "system-err": newcase.stderr = child.text elif child.tag == "failure": newcase.failure = child.text if "message" in child.attrib: newcase.failure_msg = child.attrib["message"] if not newcase.failure: newcase.failure = "failed" elif child.tag == "error": newcase.failure = child.text if "message" in child.attrib: newcase.failure_msg = child.attrib["message"] if not newcase.failure: newcase.failure = "error" elif child.tag == "properties": for property in child: newproperty = Property() newproperty.name = property.attrib["name"] newproperty.value = property.attrib["value"] newcase.properties.append(newproperty) def html(self, show_toc: bool=True): """ Render the test suite as a HTML report with links to errors first. :return: """ doc = HTMLReport(show_toc=show_toc) title = "Test Results" if self.filename: if os.path.exists(self.filename): title = os.path.basename(self.filename) doc.load(self, title=title) return str(doc)