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
|
|
|
|