diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad3b2e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IDE Specific +.vs/ +*.pyproj +*.sln \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a3c1590 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +python: + - "3.2" + - "3.3" + - "3.4" + - "3.5" + - "3.5-dev" # 3.5 development branch + - "3.6-dev" # 3.6 development branch + - "nightly" # currently points to 3.7-dev +# command to install dependencies +install: "pip install -r requirements.txt" +# command to run tests +script: py.test test.py \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5444a0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2016 Joel Wang + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..45303c4 --- /dev/null +++ b/README.rst @@ -0,0 +1,155 @@ +junitparser -- Pythonic JUnit/xUnit Result XML Parser +====================================================== + +.. image:: https://travis-ci.org/gastlygem/junitparser.svg?branch=master + +What does it do? +---------------- + +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. + +There are already a lot of modules that converts JUnit/xUnit XML from a +specific format, but you may run into some proprietory or less-known formats +and you want to convert them and feed the result to another tool. This is where +junitparser come into handy. + +Why junitparser? +---------------- + +* Functionality. There are various JUnit/xUnit XML libraries, some does + parsing, some does XML generation, some does manipulation. This module tries + to do most functions in a single package. +* Extensibility. JUnit/xUnit is hardly a standardized format. The base format + is somewhat universally agreed with, but beyond that, there could be "custom" + elements and attributes. junitparser aims to support them all, by + monkeypatching and subclassing some base classes. +* Pythonic. You can manipulate test cases and suites in a pythonic way. +* Simplicity. No external dependencies. Though it will use lxml if available. + +Installation +------------- + + pip install junitparser + +Usage +----- + +Create Junit XML format reports from scratch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from junitparser import TestCase, TestSuite, JunitXml, Skipped, Error + + # Create cases + case1 = TestCase('case1') + case1.result = Skipped() + case2 = TestCase('case2') + case2.result = Error('Example error message', 'the_error_type') + + # Create suite and add cases + suite = TestSuite('suite1') + suite.add_property('build', '55') + suite.add_testcase(case1) + suite.add_testcase(case2) + suite.delete_testcase(case2) + + # Add suite to JunitXml + xml = JunitXml() + xml.add_testsuite(suite) + xml.write('junit.xml') + +Read and manipulate exiting JUnit/xUnit XML files +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from junitparser import JUnitXml + + xml = JUnitXml('/path/to/junit.xml') + for suite in result: + # handle suites + for case in suite: + # handle cases + xml.write() # Writes back to file + + +Merge XML files +~~~~~~~~~~~~~~~ + +.. code-block:: python + + from junitparser import JUnitXml + + xml1 = JUnitXml('/path/to/junit1.xml') + xml2 = JUnitXml('/path/to/junit2.xml') + + newxml = xml1 + xml2 + # Alternatively, merge inplace + xml1 += xml2 + + +Create XML with custom attributes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from junitparser import TestCase, Attr + + # The id attribute is not supported by default + # But we can support it by monky patching + TestCase.id = Attr('id') + case = TestCase() + case.id = '123' + + print(case.tostring()) + +And you get the following output:: + + b'\n' + +Create XML with custom element +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There may be once in 1000 years you want to it this way, but anyways:: + +.. code-block:: python + + from junitparser import Element, Attr, TestSuite + + # Suppose you want to add element Custom to TestSuite. + # You can create the new element by subclassing Element, + # Then add custom attributes to it. + class Custom(Element): + _tag = 'custom' + foo = Attr() + bar = Attr() + + # Then monkeypatch TestSuite to handle Custom + # TODO: Tricky part, update later + + +TODO +---- + + +Notes +----- + +Python 2 is not supported. Currently there is no plan to support Python 2. + +There are some other packages providing similar functionalities, +https://pypi.python.org/pypi/xunitparser/ +https://pypi.python.org/pypi/xunitgen +https://pypi.python.org/pypi/xunitmerge +https://pypi.python.org/pypi/junit-xml + + + + +Usage +----- + + +_`junit-xml`: https://pypi.python.org/pypi/junit-xml diff --git a/junitparser.py b/junitparser.py new file mode 100644 index 0000000..aa5e735 --- /dev/null +++ b/junitparser.py @@ -0,0 +1,330 @@ +""" +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. + +:copyright: (c) 2016 by Joel Wang. +:license: Apache2, see LICENSE for more details. +""" + +try: + from lxml import etree +except ImportError: + from xml.etree import ElementTree as etree + + +class JUnitXmlError(Exception): + "Exception for JUnit XML related errors." + + +class Attr: + "XML element attribute descriptor." + def __init__(self, name=None): + self.name = name + def __get__(self, instance, cls): + "Gets value from attribute, return None if attribute doesn't exist." + return instance._elem.attrib.get(self.name) + def __set__(self, instance, value): + "Sets XML element attribute." + instance._elem.attrib[self.name] = value + + +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 + + +class junitxml(type): + "Metaclass to decorate the xml class" + def __new__(meta, name, bases, methods): + cls = super().__new__(meta, name, bases, methods) + cls = attributed(cls) + return cls + + +class Element(metaclass=junitxml): + "Base class for all Junit elements." + def __init__(self, name): + self._elem = etree.Element(name) + def __hash__(self): + return hash(etree.tostring(self._elem)) + def append(self, elem): + "Append an child element to current element." + self._elem.append(elem._elem) + @classmethod + def fromstring(cls, text): + "Construct Junit objects with XML string." + instance = cls() + instance._elem = etree.fromstring(text) + return instance + @classmethod + def fromelem(cls, elem): + "Constructs Junit objects with an element." + if elem is None: + return + instance = cls() + instance._elem = elem + for attr in vars(instance): + if isinstance(getattr(instance, attr), Attr): + setattr(instance, attr, instance._elem.attrib.get(attr)) + return instance + def iterchildren(self, Child): + "Iterate through specified Child type elements." + elems = self._elem.iterfind(Child._tag) + for elem in elems: + yield Child.fromelem(elem) + def child(self, Child): + "Find a single child of specified type." + elem = self._elem.find(Child._tag) + return Child.fromelem(elem) + def remove(self, instance): + for elem in self._elem.iterfind(instance._tag): + child = instance.__class__.fromelem(elem) + if child == instance: + self._elem.remove(child._elem) + def tostring(self): + "Converts element to XML string." + return etree.tostring(self._elem, encoding = 'utf-8', pretty_print=True) + + +class JUnitXml(Element): + _tag = 'testsuites' + def __init__(self): + super().__init__(self._tag) + 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): + for suite in other: + self.add_testsuite(suite) + return self + def add_testsuite(self, suite): + self.append(suite) + @classmethod + def fromfile(cls, filepath): + instance = cls() + tree = etree.parse(filepath) + instance._elem = tree.getroot() + if instance._elem.tag != cls._tag: + raise JUnitXmlError("Invalid format.") + return instance + def write(self, filepath): + tree = etree.ElementTree(self._elem) + tree.write(filepath, encoding='utf-8', xml_declaration=True) + + +class TestSuite(Element): + _tag = 'testsuite' + name = Attr() + hostname = Attr() + time = Attr() + timestamp = Attr() + tests = Attr() + failures = Attr() + errors = Attr() + def __init__(self, name=None): + super().__init__(self._tag) + if name: + self.name = name + def __iter__(self): + return super().iterchildren(TestCase) + def __len__(self): + return len(list(self.__iter__())) + def remove_testcase(self, testcase): + for case in self: + if case == testcase: + super().remove(case) + def update_case_count(self): + tests = errors = failures = 0 + for case in self: + tests += 1 + if isinstance(case.result, Failure): + failures += 1 + elif isinstance(case.result, Error): + errors += 1 + self.tests = str(tests) + self.errors = str(errors) + self.failures = str(failures) + def add_property(self, name, value): + props = self.child(Properties) + if props is None: + props = Properties() + self.append(props) + prop = Property(name, value) + props.add_property(prop) + def add_testcase(self, testcase): + self.append(testcase) + def add_suite(self, suite): + self.append(suite) + def properties(self): + props = self.child(Properties) + if props is None: + return + for prop in props: + yield prop + def remove_property(self, property): + props = self.child(Properties) + if props is None: + return + for prop in props: + if prop == property: + props.remove(property) + def testsuites(self): + for suite in self.iterchildren(TestSuite): + yield suite + + +class Properties(Element): + _tag = 'properties' + def __init__(self): + super().__init__(self._tag) + def add_property(self, property): + self.append(property) + def __iter__(self): + return super().iterchildren(Property) + + +class Property(Element): + _tag = 'property' + name = Attr() + value = Attr() + def __init__(self, name=None, value=None): + super().__init__(self._tag) + if name: + self.name = name + if value: + self.value = value + def __eq__(self, other): + return self.name == other.name + + +class Result(Element): + _tag = None + message = Attr() + type = Attr() + def __init__(self, message=None, type=None): + super().__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) + + +class Skipped(Result): + _tag = 'skipped' + def __eq__(self, other): + return super().__eq__(other) + + +class Failure(Result): + _tag = 'failure' + def __eq__(self, other): + return super().__eq__(other) + + +class Error(Result): + _tag = 'error' + def __eq__(self, other): + return super().__eq__(other) + + +class TestCase(Element): + _tag = 'testcase' + name = Attr() + classname = Attr() + time = Attr() + _possible_results = {Failure, Error, Skipped} + def __init__(self): + super().__init__(self._tag) + def __hash__(self): + return super().__hash__() + def __eq__(self, other): + # TODO: May not work correctly of unreliable hash method is used. + return hash(self) == hash(other) + @property + def result(self): + "One of the Failure, Skipped, and Error objects." + results = [] + for res in self._possible_results: + result = self.child(res) + if result is not None: + results.append(result) + if len(results) > 1: + raise JUnitXmlError("Only one result allowed per test case.") + elif len(results) == 0: + return None + else: + return results[0] + @result.setter + def result(self, value): + # First remove all existing results + for res in self._possible_results: + result = self.child(res) + if result is not None: + self.remove(result) + # Then add current result + self.append(value) + @property + def system_out(self): + elem = self.child(SystemOut) + if elem is not None: + return elem.text + return None + @system_out.setter + def system_out(self, value): + out = self.child(SystemOut) + if out is not None: + out.text = value + else: + out = SystemOut(value) + self.append(out) + @property + def system_err(self): + elem = self.child(SystemErr) + if elem is not None: + return elem.text + return None + @system_err.setter + def system_err(self, value): + err = self.child(SystemErr) + if err is not None: + err.text = value + else: + err = SystemErr(value) + self.append(err) + + +class System(Element): + "Parent class for SystemOut and SystemErr" + _tag = '' + def __init__(self, content=None): + super().__init__(self._tag) + self.text = content + @property + def text(self): + return self._elem.text + @text.setter + def text(self, value): + self._elem.text = value + + +class SystemOut(System): + _tag = 'system-out' + + +class SystemErr(System): + _tag = 'system-err' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55b033e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pytest \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9a635ba --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup, find_packages +import os +import sys + +def read(fname): + try: + return open(os.path.join(os.path.dirname(__file__), fname)).read() + except IOError: + return '' + +setup(name='junitparser', + version='0.1', + description='Manipulates JUnit/xUnit Result XML files', + long_description=read('README.rst'), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Topic :: Text Processing', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + url='', + author='Joel Wang', + author_email='gastlygem@gmail.com', + license='Apache 2.0', + keywords='junit xunit xml parser', + packages=find_packages(), + zip_safe=False) diff --git a/test.py b/test.py new file mode 100644 index 0000000..f003be4 --- /dev/null +++ b/test.py @@ -0,0 +1,253 @@ +import unittest +from junitparser import TestCase, TestSuite, Skipped, Failure, Error, Attr, JUnitXmlError, JUnitXml +from xml.etree import ElementTree as etree + + +class Test_JunitXml(unittest.TestCase): + def test_fromstring(self): + text = """ + + + + + """ + result = JUnitXml.fromstring(text) + self.assertEqual(len(result), 2) + def test_add_suite(self): + suite1 = TestSuite() + suite2 = TestSuite() + result = JUnitXml() + result.add_testsuite(suite1) + result.add_testsuite(suite2) + self.assertEqual(len(result), 2) + def test_construct_xml(self): + suite1 = TestSuite() + suite1.name = 'suite1' + case1 = TestCase() + case1.name = 'case1' + suite1.add_testcase(case1) + result = JUnitXml() + result.add_testsuite(suite1) + self.assertEqual(result._elem.tag, 'testsuites') + suite = result._elem.findall('testsuite') + self.assertEqual(len(suite), 1) + self.assertEqual(suite[0].attrib['name'], 'suite1') + case = suite[0].findall('testcase') + self.assertEqual(len(case), 1) + self.assertEqual(case[0].attrib['name'], 'case1') + def test_add(self): + result1 = JUnitXml() + suite1 = TestSuite() + result1.add_testsuite(suite1) + result2 = JUnitXml() + suite2 = TestSuite() + result2.add_testsuite(suite2) + result3 = result1 + result2 + self.assertEqual(len(result3), 2) + def test_iadd(self): + result1 = JUnitXml() + suite1 = TestSuite() + result1.add_testsuite(suite1) + result2 = JUnitXml() + suite2 = TestSuite() + result2.add_testsuite(suite2) + result1 += result2 + self.assertEqual(len(result1), 2) + + +class Test_RealFile(unittest.TestCase): + def setUp(self): + import tempfile + self.tmp = tempfile.mktemp(suffix='.xml') + def tearDown(self): + import os + os.remove(self.tmp) + def test_fromfile(self): + text = """ + + + + + + + + + + Assertion failed + + + + + + +""" + with open(self.tmp, 'w') as f: + f.write(text) + xml = JUnitXml.fromfile(self.tmp) + suite1, suite2 = list(iter(xml)) + self.assertEqual(len(list(suite1.properties())), 0) + self.assertEqual(len(list(suite2.properties())), 3) + self.assertEqual(len(suite2), 3) + self.assertEqual(suite2.name, 'JUnitXmlReporter.constructor') + self.assertEqual(suite2.tests, '3') + case_results = [Failure, Skipped, type(None)] + for case, result in zip(suite2, case_results): + self.assertIsInstance(case.result, result) + def test_write(self): + suite1 = TestSuite() + suite1.name = 'suite1' + case1 = TestCase() + case1.name = 'case1' + suite1.add_testcase(case1) + result = JUnitXml() + result.add_testsuite(suite1) + result.write(self.tmp) + with open(self.tmp) as f: + text = f.read() + self.assertIn('suite1', text) + self.assertIn('case1', text) + +class Test_TestSuite(unittest.TestCase): + def test_fromstring(self): + text = """ + + + """ + suite = TestSuite.fromstring(text) + suite.update_case_count() + self.assertEqual(suite.name, 'suitename') + self.assertEqual(suite.tests, '1') + def test_props_fromstring(self): + text = """ + + """ + suite = TestSuite.fromstring(text) + for prop in suite.properties(): + self.assertEqual(prop.name, 'name1') + self.assertEqual(prop.value, 'value1') + def test_len(self): + text = """ + + """ + suite = TestSuite.fromstring(text) + self.assertEqual(len(suite), 2) + def test_add_case(self): + suite = TestSuite() + case1 = TestCase() + case2 = TestCase() + case2.result = Failure() + case3 = TestCase() + case3.result = Error() + suite.add_testcase(case1) + suite.add_testcase(case2) + suite.add_testcase(case3) + suite.update_case_count() + self.assertEqual(suite.tests, '3') + self.assertEqual(suite.failures, '1') + self.assertEqual(suite.errors, '1') + def test_add_property(self): + suite = TestSuite() + suite.add_property('name1', 'value1') + res_prop = next(suite.properties()) + self.assertEqual(res_prop.name, 'name1') + self.assertEqual(res_prop.value, 'value1') + def test_remove_case(self): + suite = TestSuite() + case1 = TestCase() + case1.name = 'test1' + case2 = TestCase() + case2.name = 'test2' + suite.add_testcase(case1) + suite.add_testcase(case2) + suite.remove_testcase(case1) + self.assertEqual(len(suite), 1) + def test_remove_property(self): + suite = TestSuite() + suite.add_property('name1', 'value1') + suite.add_property('name2', 'value2') + suite.add_property('name3', 'value3') + for prop in suite.properties(): + if prop.name == 'name2': + suite.remove_property(prop) + self.assertEqual(len(list(suite.properties())), 2) + def test_suite_in_suite(self): + suite = TestSuite('parent') + childsuite = TestSuite('child') + suite.add_suite(childsuite) + self.assertEqual(len(list(suite.testsuites())), 1) + + +class Test_TestCase(unittest.TestCase): + def test_fromstring(self): + text = """ + + System out + System err + """ + case = TestCase.fromstring(text) + self.assertEqual(case.name, "testname") + self.assertIsInstance(case.result, Failure) + self.assertEqual(case.system_out, "System out") + self.assertEqual(case.system_err, "System err") + def test_illegal_xml_multi_results(self): + text = """ + + + + """ + case = TestCase.fromstring(text) + self.assertRaises(JUnitXmlError) + def test_case_attributes(self): + case = TestCase() + case.name = 'testname' + case.classname = 'testclassname' + case.time = '15.123' + case.result = Skipped() + self.assertEqual(case.name, 'testname') + self.assertEqual(case.classname, 'testclassname') + self.assertEqual(case.time, '15.123') + self.assertIsInstance(case.result, Skipped) + def test_case_output(self): + case = TestCase() + case.system_err = 'error message' + case.system_out = 'out message' + self.assertEqual(case.system_err, 'error message') + self.assertEqual(case.system_out, 'out message') + case.system_err = 'error2' + case.system_out = 'out2' + self.assertEqual(case.system_err, 'error2') + self.assertEqual(case.system_out, 'out2') + def test_set_multiple_results(self): + case = TestCase() + case.result = Skipped() + case.result = Failure() + self.assertIsInstance(case.result, Failure) + def test_monkypatch(self): + TestCase.id = Attr('id') + case = TestCase() + case.id = "100" + self.assertEqual(case.id, "100") + def test_equal(self): + case = TestCase() + case.name = 'test1' + case2 = TestCase() + case2.name = 'test1' + self.assertEqual(case, case2) + def test_not_equal(self): + case = TestCase() + case.name = 'test1' + case2 = TestCase() + case2.name = 'test2' + self.assertNotEqual(case, case2) + def test_from_elem(self): + elem = etree.Element('testcase', name='case1') + case = TestCase.fromelem(elem) + self.assertEqual(case.name, 'case1') + def test_to_string(self): + case = TestCase() + case.name = 'test1' + case_str = case.tostring() + self.assertEqual(case_str, b'\n') + +if __name__ == '__main__': + unittest.main()