|
1
|
|
|
# -*- coding: utf-8 -*- |
|
2
|
|
|
# ----------------------------------------------------------------------------- |
|
3
|
|
|
# Copyright (c) 2016 Continuum Analytics, Inc. |
|
4
|
|
|
# |
|
5
|
|
|
# Licensed under the terms of the MIT License |
|
6
|
|
|
# (see LICENSE.txt for details) |
|
7
|
|
|
# ----------------------------------------------------------------------------- |
|
8
|
|
|
"""Generic tools and custom test runner.""" |
|
9
|
|
|
|
|
10
|
|
|
# Standard library imports |
|
11
|
|
|
from collections import OrderedDict |
|
12
|
|
|
import ast |
|
13
|
|
|
import json |
|
14
|
|
|
import os |
|
15
|
|
|
|
|
16
|
|
|
# Third party imports |
|
17
|
|
|
from pytest_cov.plugin import CoverageError |
|
18
|
|
|
from six import PY2 |
|
19
|
|
|
from six.moves import configparser |
|
20
|
|
|
import pytest |
|
21
|
|
|
|
|
22
|
|
|
# Local imports |
|
23
|
|
|
from ciocheck.config import COVERAGE_CONFIGURATION_FILE |
|
24
|
|
|
from ciocheck.utils import ShortOutput, cpu_count |
|
25
|
|
|
|
|
26
|
|
|
|
|
27
|
|
|
class Tool(object): |
|
28
|
|
|
"""Generic tool object.""" |
|
29
|
|
|
|
|
30
|
|
|
name = None |
|
31
|
|
|
language = None |
|
32
|
|
|
extensions = None |
|
33
|
|
|
|
|
34
|
|
|
command = None |
|
35
|
|
|
|
|
36
|
|
|
# Config |
|
37
|
|
|
config_file = None # '.validconfigfilename' |
|
38
|
|
|
config_sections = None # (('ciocheck:section', 'section')) |
|
39
|
|
|
|
|
40
|
|
|
def __init__(self, cmd_root): |
|
41
|
|
|
"""A Generic tool object.""" |
|
42
|
|
|
self.cmd_root = cmd_root |
|
43
|
|
|
self.config = None |
|
44
|
|
|
self.config_options = None # dict version of the config |
|
45
|
|
|
|
|
46
|
|
|
def create_config(self, config): |
|
47
|
|
|
"""Create a config file for for a given config fname and sections.""" |
|
48
|
|
|
self.config = config |
|
49
|
|
|
|
|
50
|
|
|
if self.config_file and self.config_sections: |
|
51
|
|
|
new_config = configparser.ConfigParser() |
|
52
|
|
|
new_config_file = os.path.join(self.cmd_root, self.config_file) |
|
53
|
|
|
|
|
54
|
|
|
for (cio_config_section, config_section) in self.config_sections: |
|
55
|
|
|
if config.has_section(cio_config_section): |
|
56
|
|
|
items = config.items(cio_config_section) |
|
57
|
|
|
new_config.add_section(config_section) |
|
58
|
|
|
|
|
59
|
|
|
for option, value in items: |
|
60
|
|
|
new_config.set(config_section, option, value) |
|
61
|
|
|
|
|
62
|
|
|
with open(new_config_file, 'w') as file_obj: |
|
63
|
|
|
new_config.write(file_obj) |
|
64
|
|
|
|
|
65
|
|
|
@classmethod |
|
66
|
|
|
def make_config_dictionary(cls): |
|
67
|
|
|
"""Turn config into a dictionary for later usage.""" |
|
68
|
|
|
config_path = os.path.join(cls.cmd_root, cls.config_file) |
|
69
|
|
|
config_options = {} |
|
70
|
|
|
|
|
71
|
|
|
if os.path.exists(config_path): |
|
72
|
|
|
config = configparser.ConfigParser() |
|
73
|
|
|
|
|
74
|
|
|
with open(config_path, 'r') as file_obj: |
|
75
|
|
|
config.readfp(file_obj) |
|
76
|
|
|
|
|
77
|
|
|
for section in config.sections(): |
|
78
|
|
|
for key in config[section]: |
|
79
|
|
|
value = config[section][key] |
|
80
|
|
|
if ',' in value: |
|
81
|
|
|
value = [v for v in value.split(',') if v] |
|
82
|
|
|
elif value.lower() == 'false': |
|
83
|
|
|
value = False |
|
84
|
|
|
elif value.lower() == 'true': |
|
85
|
|
|
value = True |
|
86
|
|
|
else: |
|
87
|
|
|
try: |
|
88
|
|
|
value = ast.literal_eval(value) # Numbers |
|
89
|
|
|
except Exception as err: |
|
90
|
|
|
print(err) |
|
91
|
|
|
|
|
92
|
|
|
config_options[key.replace('-', '_')] = value |
|
93
|
|
|
|
|
94
|
|
|
return config_options |
|
95
|
|
|
|
|
96
|
|
|
@classmethod |
|
97
|
|
|
def remove_config(cls, path): |
|
98
|
|
|
"""Remove config file.""" |
|
99
|
|
|
if cls.config_file and cls.config_sections: |
|
100
|
|
|
remove_file = os.path.join(path, cls.config_file) |
|
101
|
|
|
if os.path.isfile(remove_file): |
|
102
|
|
|
os.remove(remove_file) |
|
103
|
|
|
|
|
104
|
|
|
def run(self, paths): |
|
105
|
|
|
"""Run the tool.""" |
|
106
|
|
|
raise NotImplementedError |
|
107
|
|
|
|
|
108
|
|
|
|
|
109
|
|
|
class CoverageTool(Tool): |
|
110
|
|
|
"""Coverage tool runner.""" |
|
111
|
|
|
|
|
112
|
|
|
name = 'coverage' |
|
113
|
|
|
language = 'python' |
|
114
|
|
|
extensions = ('py', ) |
|
115
|
|
|
|
|
116
|
|
|
# Config |
|
117
|
|
|
config_file = COVERAGE_CONFIGURATION_FILE |
|
118
|
|
|
config_sections = [ |
|
119
|
|
|
('coverage:run', 'run'), |
|
120
|
|
|
('coverage:report', 'report'), |
|
121
|
|
|
('coverage:html', 'html'), |
|
122
|
|
|
('coverage:xml', 'xml'), |
|
123
|
|
|
] |
|
124
|
|
|
|
|
125
|
|
|
def _monkey_path_coverage(self): |
|
126
|
|
|
"""Enforce the value of `skip_covered`, ignored by pytest-cov. |
|
127
|
|
|
|
|
128
|
|
|
pytest-cov ignores the option even if included in the .coveragerc |
|
129
|
|
|
configuration file. |
|
130
|
|
|
""" |
|
131
|
|
|
|
|
132
|
|
|
# try: |
|
133
|
|
|
# original_init = coverage.summary.SummaryReporter.__init__ |
|
134
|
|
|
# |
|
135
|
|
|
# def modified_init(self, coverage, config): |
|
136
|
|
|
# config.skip_covered = True |
|
137
|
|
|
# original_init(self, coverage, config) |
|
138
|
|
|
# |
|
139
|
|
|
# coverage.summary.SummaryReporter.__init__ = modified_init |
|
140
|
|
|
# |
|
141
|
|
|
# print("\nCoverage monkeypatched to skip_covered") |
|
142
|
|
|
# except Exception as e: |
|
143
|
|
|
# print("\nFailed to monkeypatch coverage: {0}".format(str(e)), |
|
144
|
|
|
# file=sys.stderr) |
|
145
|
|
|
|
|
146
|
|
|
def run(self, paths): |
|
147
|
|
|
"""Run the tool.""" |
|
148
|
|
|
return [] |
|
149
|
|
|
|
|
150
|
|
|
@classmethod |
|
151
|
|
|
def remove_config(cls, path): |
|
152
|
|
|
"""Remove config file.""" |
|
153
|
|
|
pass |
|
154
|
|
|
|
|
155
|
|
|
|
|
156
|
|
|
class PytestTool(Tool): |
|
157
|
|
|
"""Pytest tool runner.""" |
|
158
|
|
|
|
|
159
|
|
|
name = 'pytest' |
|
160
|
|
|
language = 'python' |
|
161
|
|
|
extensions = ('py', ) |
|
162
|
|
|
|
|
163
|
|
|
config_file = 'pytest.ini' |
|
164
|
|
|
config_sections = [('pytest', 'pytest')] |
|
165
|
|
|
|
|
166
|
|
|
REPORT_FILE = '.pytestreport.json' |
|
167
|
|
|
|
|
168
|
|
|
def __init__(self, cmd_root): |
|
169
|
|
|
"""Pytest tool runner.""" |
|
170
|
|
|
super(PytestTool, self).__init__(cmd_root) |
|
171
|
|
|
self.pytest_args = None |
|
172
|
|
|
self.output = None |
|
173
|
|
|
self.coverage_fail = False |
|
174
|
|
|
|
|
175
|
|
|
def setup_pytest_coverage_args(self, paths): |
|
176
|
|
|
"""Setup pytest-cov arguments and config file path.""" |
|
177
|
|
|
if isinstance(paths, (dict, OrderedDict)): |
|
178
|
|
|
paths = list(sorted(paths.keys())) |
|
179
|
|
|
|
|
180
|
|
|
for path in paths: |
|
181
|
|
|
if os.path.isdir(path): |
|
182
|
|
|
cov = '--cov={0}'.format(path) |
|
183
|
|
|
coverage_args = [cov] |
|
184
|
|
|
break |
|
185
|
|
|
else: |
|
186
|
|
|
coverage_args = [] |
|
187
|
|
|
|
|
188
|
|
|
coverage_config_file = os.path.join(self.cmd_root, |
|
189
|
|
|
COVERAGE_CONFIGURATION_FILE) |
|
190
|
|
|
if os.path.isfile(coverage_config_file): |
|
191
|
|
|
cov_config = ['--cov-config', coverage_config_file] |
|
192
|
|
|
coverage_args = cov_config + coverage_args |
|
193
|
|
|
|
|
194
|
|
|
if PY2: |
|
195
|
|
|
# xdist appears to lock up the test suite with python2, maybe due |
|
196
|
|
|
# to an interaction with coverage |
|
197
|
|
|
enable_xdist = [] |
|
198
|
|
|
else: |
|
199
|
|
|
enable_xdist = ['-n', str(cpu_count())] |
|
200
|
|
|
|
|
201
|
|
|
self.pytest_args = ['--json={0}'.format(self.REPORT_FILE)] |
|
202
|
|
|
self.pytest_args = self.pytest_args + enable_xdist |
|
203
|
|
|
self.pytest_args = self.pytest_args + coverage_args |
|
204
|
|
|
|
|
205
|
|
|
def run(self, paths): |
|
206
|
|
|
"""Run pytest test suite.""" |
|
207
|
|
|
cmd = paths + self.pytest_args |
|
208
|
|
|
print(cmd) |
|
209
|
|
|
|
|
210
|
|
|
try: |
|
211
|
|
|
with ShortOutput(self.cmd_root) as so: |
|
212
|
|
|
errno = pytest.main(cmd) |
|
213
|
|
|
output_lines = ''.join(so.output).lower() |
|
214
|
|
|
|
|
215
|
|
|
if 'FAIL Required test coverage'.lower() in output_lines: |
|
216
|
|
|
self.coverage_fail = True |
|
217
|
|
|
|
|
218
|
|
|
if errno != 0: |
|
219
|
|
|
print("pytest failed, code {errno}".format(errno=errno)) |
|
220
|
|
|
except CoverageError as e: |
|
221
|
|
|
print("Test coverage failure: " + str(e)) |
|
222
|
|
|
self.coverage_fail = True |
|
223
|
|
|
|
|
224
|
|
|
covered_lines = self.parse_coverage() |
|
225
|
|
|
pytest_report = self.parse_pytest_report() |
|
226
|
|
|
|
|
227
|
|
|
results = {'coverage': covered_lines} |
|
228
|
|
|
if pytest_report is not None: |
|
229
|
|
|
results['pytest'] = pytest_report |
|
230
|
|
|
return results |
|
231
|
|
|
|
|
232
|
|
|
def parse_pytest_report(self): |
|
233
|
|
|
"""Parse pytest json resport generated by pytest-json.""" |
|
234
|
|
|
data = None |
|
235
|
|
|
pytest_report_path = os.path.join(self.cmd_root, self.REPORT_FILE) |
|
236
|
|
|
if os.path.isfile(pytest_report_path): |
|
237
|
|
|
with open(pytest_report_path, 'r') as file_obj: |
|
238
|
|
|
data = json.load(file_obj) |
|
239
|
|
|
return data |
|
240
|
|
|
|
|
241
|
|
|
def parse_coverage(self): |
|
242
|
|
|
"""Parse .coverage json report generated by coverage.""" |
|
243
|
|
|
coverage_string = ("!coverage.py: This is a private format, don't " |
|
244
|
|
|
"read it directly!") |
|
245
|
|
|
coverage_path = os.path.join(self.cmd_root, '.coverage') |
|
246
|
|
|
|
|
247
|
|
|
covered_lines = {} |
|
248
|
|
|
if os.path.isfile(coverage_path): |
|
249
|
|
|
with open(coverage_path, 'r') as file_obj: |
|
250
|
|
|
data = file_obj.read() |
|
251
|
|
|
data = data.replace(coverage_string, '') |
|
252
|
|
|
|
|
253
|
|
|
cov = json.loads(data) |
|
254
|
|
|
covered_lines = OrderedDict() |
|
255
|
|
|
lines = cov['lines'] |
|
256
|
|
|
for path in sorted(lines): |
|
257
|
|
|
covered_lines[path] = lines[path] |
|
258
|
|
|
return covered_lines |
|
259
|
|
|
|
|
260
|
|
|
@classmethod |
|
261
|
|
|
def remove_config(cls, path): |
|
262
|
|
|
"""Remove config file.""" |
|
263
|
|
|
super(PytestTool, cls).remove_config(path) |
|
264
|
|
|
remove_file = os.path.join(path, cls.REPORT_FILE) |
|
265
|
|
|
if os.path.isfile(remove_file): |
|
266
|
|
|
os.remove(remove_file) |
|
267
|
|
|
|
|
268
|
|
|
|
|
269
|
|
|
TOOLS = [ |
|
270
|
|
|
CoverageTool, |
|
271
|
|
|
PytestTool, |
|
272
|
|
|
] |
|
273
|
|
|
|
|
274
|
|
|
|
|
275
|
|
|
def test(): |
|
276
|
|
|
"""Main local test.""" |
|
277
|
|
|
pass |
|
278
|
|
|
|
|
279
|
|
|
|
|
280
|
|
|
if __name__ == '__main__': |
|
281
|
|
|
test() |
|
282
|
|
|
|