~canonical-platform-qa/snappy-ecosystem-tests/fixing_ci

« back to all changes in this revision

Viewing changes to snappy_ecosystem_tests/plugins/junitxml.py

  • Committer: Heber Parrucci
  • Date: 2017-02-13 16:00:04 UTC
  • mto: This revision was merged to the branch mainline in revision 14.
  • Revision ID: heber.parrucci@canonical.com-20170213160004-oe9fnfkd6foqllae
Changing runner for pytest to aviod the plugins issues in nose2 using testtools.
Making pylint more strict: fail with Warnings messages.
Refactoring global variables to use Singletons instead.
Updating README.rst with the new runner options

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
 
 
3
 
#
4
 
# Snappy Ecosystem Tests
5
 
# Copyright (C) 2017 Canonical
6
 
#
7
 
# This program is free software: you can redistribute it and/or modify
8
 
# it under the terms of the GNU General Public License as published by
9
 
# the Free Software Foundation, either version 3 of the License, or
10
 
# (at your option) any later version.
11
 
#
12
 
# This program is distributed in the hope that it will be useful,
13
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 
# GNU General Public License for more details.
16
 
#
17
 
# You should have received a copy of the GNU General Public License
18
 
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
 
#
20
 
 
21
 
"""
22
 
Output test reports in junit-xml format.
23
 
 
24
 
This plugin implements :func:`startTest`, :func:`testOutcome` and
25
 
:func:`stopTestRun` to compile and then output a test report in
26
 
junit-xml format. By default, the report is written to a file called
27
 
``nose2-junit.xml`` in the current working directory. 
28
 
 
29
 
You can configure the output filename by setting ``path`` in a ``[junit-xml]``
30
 
section in a config file.  Unicode characters which are invalid in XML 1.0
31
 
are replaced with the ``U+FFFD`` replacement character.  In the case that your
32
 
software throws an error with an invalid byte string.  
33
 
 
34
 
By default, the ranges of discouraged characters are replaced as well.  This can be
35
 
changed by setting the ``keep_restricted`` configuration variable to ``True``.
36
 
 
37
 
"""
38
 
 
39
 
# Based on unittest2/plugins/junitxml.py,
40
 
# which is itself based on the junitxml plugin from py.test
41
 
 
42
 
import os.path
43
 
import time
44
 
import re
45
 
import sys
46
 
import json
47
 
from xml.etree import ElementTree as ET
48
 
 
49
 
import six
50
 
 
51
 
from nose2 import events, result
52
 
from snappy_ecosystem_tests.plugins.utils import exc_info_to_string
53
 
 
54
 
__unittest = True
55
 
 
56
 
 
57
 
class JUnitXmlReporter(events.Plugin):
58
 
    """Output junit-xml test report to file"""
59
 
    configSection = 'custom-junit-xml'
60
 
    commandLineSwitch = ('X', 'junit-xml', 'Generate junit-xml output report')
61
 
 
62
 
    def __init__(self):
63
 
        self.path = os.path.realpath(
64
 
            self.config.as_str('path', default='nose2-junit.xml'))
65
 
        self.keep_restricted = self.config.as_bool('keep_restricted',
66
 
                                                   default=False)
67
 
        self.test_properties = self.config.as_str('test_properties',
68
 
                                                  default=None)
69
 
        if self.test_properties is not None:
70
 
            self.test_properties_path = os.path.realpath(self.test_properties)
71
 
        self.errors = 0
72
 
        self.failed = 0
73
 
        self.skipped = 0
74
 
        self.numtests = 0
75
 
        self.tree = ET.Element('testsuite')
76
 
        self._start = None
77
 
 
78
 
    def startTest(self, event):
79
 
        """Count test, record start time"""
80
 
        self.numtests += 1
81
 
        self._start = event.startTime
82
 
 
83
 
    def testOutcome(self, event):
84
 
        """Add test outcome to xml tree"""
85
 
        test = event.test
86
 
        testid = test.id().split('\n')[0]
87
 
        # split into module, class, method parts... somehow
88
 
        parts = testid.split('.')
89
 
        classname = '.'.join(parts[:-1])
90
 
        method = parts[-1]
91
 
 
92
 
        testcase = ET.SubElement(self.tree, 'testcase')
93
 
        testcase.set('time', "%.6f" % self._time())
94
 
        testcase.set('classname', classname)
95
 
        testcase.set('name', method)
96
 
 
97
 
        msg = ''
98
 
        if event.exc_info:
99
 
            msg = exc_info_to_string(event.exc_info, test)
100
 
        elif event.reason:
101
 
            msg = event.reason
102
 
 
103
 
        msg = string_cleanup(msg, self.keep_restricted)
104
 
 
105
 
        if event.outcome == result.ERROR:
106
 
            self.errors += 1
107
 
            error = ET.SubElement(testcase, 'error')
108
 
            error.set('message', 'test failure')
109
 
            error.text = msg
110
 
        elif event.outcome == result.FAIL and not event.expected:
111
 
            self.failed += 1
112
 
            failure = ET.SubElement(testcase, 'failure')
113
 
            failure.set('message', 'test failure')
114
 
            failure.text = msg
115
 
        elif event.outcome == result.PASS and not event.expected:
116
 
            self.skipped += 1
117
 
            skipped = ET.SubElement(testcase, 'skipped')
118
 
            skipped.set('message', 'test passes unexpectedly')
119
 
        elif event.outcome == result.SKIP:
120
 
            self.skipped += 1
121
 
            skipped = ET.SubElement(testcase, 'skipped')
122
 
        elif event.outcome == result.FAIL and event.expected:
123
 
            self.skipped += 1
124
 
            skipped = ET.SubElement(testcase, 'skipped')
125
 
            skipped.set('message', 'expected test failure')
126
 
            skipped.text = msg
127
 
 
128
 
        system_err = ET.SubElement(testcase, 'system-err')
129
 
        system_err.text = string_cleanup(
130
 
            '\n'.join(event.metadata.get('logs', '')),
131
 
            self.keep_restricted
132
 
        )
133
 
 
134
 
    def _check(self):
135
 
        if not os.path.exists(os.path.dirname(self.path)):
136
 
            raise IOError(2, 'JUnitXML: Parent folder does not exist for file',
137
 
                          self.path)
138
 
        if self.test_properties is not None:
139
 
            if not os.path.exists(self.test_properties_path):
140
 
                raise IOError(2, 'JUnitXML: Properties file does not exist',
141
 
                              self.test_properties_path)
142
 
 
143
 
    def stopTestRun(self, event):
144
 
        """Output xml tree to file"""
145
 
        self.tree.set('name', 'nose2-junit')
146
 
        self.tree.set('errors', str(self.errors))
147
 
        self.tree.set('failures', str(self.failed))
148
 
        self.tree.set('skipped', str(self.skipped))
149
 
        self.tree.set('tests', str(self.numtests))
150
 
        self.tree.set('time', "%.3f" % event.timeTaken)
151
 
 
152
 
        self._check()
153
 
        self._include_test_properties()
154
 
        self._indent_tree(self.tree)
155
 
 
156
 
        output = ET.ElementTree(self.tree)
157
 
        output.write(self.path, encoding="utf-8")
158
 
 
159
 
    def _include_test_properties(self):
160
 
        """Include test properties in xml tree"""
161
 
        if self.test_properties is None:
162
 
            return
163
 
 
164
 
        props = {}
165
 
        with open(self.test_properties_path) as data:
166
 
            try:
167
 
                props = json.loads(data.read())
168
 
            except ValueError:
169
 
                raise ValueError('JUnitXML: could not decode file: \'%s\'' %
170
 
                                 self.test_properties_path)
171
 
 
172
 
        properties = ET.SubElement(self.tree, 'properties')
173
 
        for key, val in props.items():
174
 
            prop = ET.SubElement(properties, 'property')
175
 
            prop.set('name', key)
176
 
            prop.set('value', val)
177
 
 
178
 
    def _indent_tree(self, elem, level=0):
179
 
        """In-place pretty formatting of the ElementTree structure."""
180
 
        i = "\n" + level * "  "
181
 
        if len(elem):
182
 
            if not elem.text or not elem.text.strip():
183
 
                elem.text = i + "  "
184
 
            if not elem.tail or not elem.tail.strip():
185
 
                elem.tail = i
186
 
            for elem in elem:
187
 
                self._indent_tree(elem, level + 1)
188
 
            if not elem.tail or not elem.tail.strip():
189
 
                elem.tail = i
190
 
        else:
191
 
            if level and (not elem.tail or not elem.tail.strip()):
192
 
                elem.tail = i
193
 
 
194
 
    def _time(self):
195
 
        try:
196
 
            return time.time() - self._start
197
 
        except Exception:
198
 
            pass
199
 
        finally:
200
 
            self._start = None
201
 
        return 0
202
 
 
203
 
 
204
 
#
205
 
# xml utility functions
206
 
#
207
 
 
208
 
# six doesn't include a unichr function
209
 
 
210
 
 
211
 
def _unichr(string):
212
 
    return chr(string)
213
 
 
214
 
# etree outputs XML 1.0 so the 1.1 Restricted characters are invalid.
215
 
# and there are no characters that can be given as entities aside
216
 
# form & < > ' " which ever have to be escaped (etree handles these fine)
217
 
ILLEGAL_RANGES = [(0x00, 0x08), (0x0B, 0x0C), (0x0E, 0x1F),
218
 
                  (0xD800, 0xDFFF), (0xFFFE, 0xFFFF)]
219
 
# 0xD800 thru 0xDFFF are technically invalid in UTF-8 but PY2 will encode
220
 
# bytes into these but PY3 will do a replacement
221
 
 
222
 
# Other non-characters which are not strictly forbidden but
223
 
# discouraged.
224
 
RESTRICTED_RANGES = [(0x7F, 0x84), (0x86, 0x9F), (0xFDD0, 0xFDDF)]
225
 
# check for a wide build
226
 
if sys.maxunicode > 0xFFFF:
227
 
    RESTRICTED_RANGES += [(0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF),
228
 
                          (0x3FFFE, 0x3FFFF), (0x4FFFE, 0x4FFFF),
229
 
                          (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF),
230
 
                          (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF),
231
 
                          (0x9FFFE, 0x9FFFF), (0xAFFFE, 0xAFFFF),
232
 
                          (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF),
233
 
                          (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF),
234
 
                          (0xFFFFE, 0xFFFFF), (0x10FFFE, 0x10FFFF)]
235
 
 
236
 
ILLEGAL_REGEX_STR = \
237
 
    six.u('[') + \
238
 
    six.u('').join(["%s-%s" % (_unichr(l), _unichr(h))
239
 
                    for (l, h) in ILLEGAL_RANGES]) + \
240
 
    six.u(']')
241
 
RESTRICTED_REGEX_STR = \
242
 
    six.u('[') + \
243
 
    six.u('').join(["%s-%s" % (_unichr(l), _unichr(h))
244
 
                    for (l, h) in RESTRICTED_RANGES]) + \
245
 
    six.u(']')
246
 
 
247
 
_ILLEGAL_REGEX = re.compile(ILLEGAL_REGEX_STR, re.U)
248
 
_RESTRICTED_REGEX = re.compile(RESTRICTED_REGEX_STR, re.U)
249
 
 
250
 
 
251
 
def string_cleanup(string, keep_restricted=False):
252
 
    if not issubclass(type(string), six.text_type):
253
 
        string = six.text_type(string, encoding='utf-8', errors='replace')
254
 
 
255
 
    string = _ILLEGAL_REGEX.sub(six.u('\uFFFD'), string)
256
 
    if not keep_restricted:
257
 
        string = _RESTRICTED_REGEX.sub(six.u('\uFFFD'), string)
258
 
 
259
 
    return string