blob: ebbdc780c38311d9359765d5365cece8bd3b242f [file] [log] [blame]
Chris Hoge296558c2015-02-19 00:29:49 -06001# Copyright 2014 Mirantis, Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may
4# not use this file except in compliance with the License. You may obtain
5# a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations
13# under the License.
14
15import argparse
16import ast
17import importlib
18import inspect
19import os
20import sys
21import unittest
22import urllib
23import uuid
24
25DECORATOR_MODULE = 'test'
26DECORATOR_NAME = 'idempotent_id'
27DECORATOR_IMPORT = 'tempest.%s' % DECORATOR_MODULE
28IMPORT_LINE = 'from tempest import %s' % DECORATOR_MODULE
29DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE,
30 DECORATOR_NAME)
Chris Hoge7579c1a2015-02-26 14:12:15 -080031UNIT_TESTS_EXCLUDE = 'tempest.tests'
Chris Hoge296558c2015-02-19 00:29:49 -060032
33
34class SourcePatcher(object):
35
36 """"Lazy patcher for python source files"""
37
38 def __init__(self):
39 self.source_files = None
40 self.patches = None
41 self.clear()
42
43 def clear(self):
44 """Clear inner state"""
45 self.source_files = {}
46 self.patches = {}
47
48 @staticmethod
49 def _quote(s):
50 return urllib.quote(s)
51
52 @staticmethod
53 def _unquote(s):
54 return urllib.unquote(s)
55
56 def add_patch(self, filename, patch, line_no):
57 """Add lazy patch"""
58 if filename not in self.source_files:
59 with open(filename) as f:
60 self.source_files[filename] = self._quote(f.read())
61 patch_id = str(uuid.uuid4())
62 if not patch.endswith('\n'):
63 patch += '\n'
64 self.patches[patch_id] = self._quote(patch)
65 lines = self.source_files[filename].split(self._quote('\n'))
66 lines[line_no - 1] = ''.join(('{%s:s}' % patch_id, lines[line_no - 1]))
67 self.source_files[filename] = self._quote('\n').join(lines)
68
69 def _save_changes(self, filename, source):
70 print('%s fixed' % filename)
71 with open(filename, 'w') as f:
72 f.write(source)
73
74 def apply_patches(self):
75 """Apply all patches"""
76 for filename in self.source_files:
77 patched_source = self._unquote(
78 self.source_files[filename].format(**self.patches)
79 )
80 self._save_changes(filename, patched_source)
81 self.clear()
82
83
84class TestChecker(object):
85
86 def __init__(self, package):
87 self.package = package
88 self.base_path = os.path.abspath(os.path.dirname(package.__file__))
89
90 def _path_to_package(self, path):
91 relative_path = path[len(self.base_path) + 1:]
92 if relative_path:
93 return '.'.join((self.package.__name__,) +
94 tuple(relative_path.split('/')))
95 else:
96 return self.package.__name__
97
98 def _modules_search(self):
99 """Recursive search for python modules in base package"""
100 modules = []
101 for root, dirs, files in os.walk(self.base_path):
102 if not os.path.exists(os.path.join(root, '__init__.py')):
103 continue
104 root_package = self._path_to_package(root)
105 for item in files:
106 if item.endswith('.py'):
Chris Hoge7579c1a2015-02-26 14:12:15 -0800107 module_name = '.'.join((root_package,
108 os.path.splitext(item)[0]))
109 if not module_name.startswith(UNIT_TESTS_EXCLUDE):
110 modules.append(module_name)
Chris Hoge296558c2015-02-19 00:29:49 -0600111 return modules
112
113 @staticmethod
114 def _get_idempotent_id(test_node):
115 """
116 Return key-value dict with all metadata from @test.idempotent_id
117 decorators for test method
118 """
119 idempotent_id = None
120 for decorator in test_node.decorator_list:
121 if (hasattr(decorator, 'func') and
Marc Kodererfb199c02015-03-16 11:53:44 +0100122 hasattr(decorator.func, 'attr') and
123 decorator.func.attr == DECORATOR_NAME and
124 hasattr(decorator.func, 'value') and
125 decorator.func.value.id == DECORATOR_MODULE):
Chris Hoge296558c2015-02-19 00:29:49 -0600126 for arg in decorator.args:
127 idempotent_id = ast.literal_eval(arg)
128 return idempotent_id
129
130 @staticmethod
131 def _is_decorator(line):
132 return line.strip().startswith('@')
133
134 @staticmethod
135 def _is_def(line):
136 return line.strip().startswith('def ')
137
138 def _add_uuid_to_test(self, patcher, test_node, source_path):
139 with open(source_path) as src:
140 src_lines = src.read().split('\n')
141 lineno = test_node.lineno
142 insert_position = lineno
143 while True:
144 if (self._is_def(src_lines[lineno - 1]) or
145 (self._is_decorator(src_lines[lineno - 1]) and
146 (DECORATOR_TEMPLATE.split('(')[0] <=
147 src_lines[lineno - 1].strip().split('(')[0]))):
148 insert_position = lineno
149 break
150 lineno += 1
151 patcher.add_patch(
152 source_path,
153 ' ' * test_node.col_offset + DECORATOR_TEMPLATE % uuid.uuid4(),
154 insert_position
155 )
156
157 @staticmethod
158 def _is_test_case(module, node):
159 if (node.__class__ is ast.ClassDef and
160 hasattr(module, node.name) and
161 inspect.isclass(getattr(module, node.name))):
162 return issubclass(getattr(module, node.name), unittest.TestCase)
163
164 @staticmethod
165 def _is_test_method(node):
166 return (node.__class__ is ast.FunctionDef
167 and node.name.startswith('test_'))
168
169 @staticmethod
170 def _next_node(body, node):
171 if body.index(node) < len(body):
172 return body[body.index(node) + 1]
173
174 @staticmethod
175 def _import_name(node):
176 if type(node) == ast.Import:
177 return node.names[0].name
178 elif type(node) == ast.ImportFrom:
179 return '%s.%s' % (node.module, node.names[0].name)
180
181 def _add_import_for_test_uuid(self, patcher, src_parsed, source_path):
182 with open(source_path) as f:
183 src_lines = f.read().split('\n')
184 line_no = 0
185 tempest_imports = [node for node in src_parsed.body
186 if self._import_name(node) and
187 'tempest.' in self._import_name(node)]
188 if not tempest_imports:
189 import_snippet = '\n'.join(('', IMPORT_LINE, ''))
190 else:
191 for node in tempest_imports:
192 if self._import_name(node) < DECORATOR_IMPORT:
193 continue
194 else:
195 line_no = node.lineno
196 import_snippet = IMPORT_LINE
197 break
198 else:
199 line_no = tempest_imports[-1].lineno
200 while True:
201 if (not src_lines[line_no - 1] or
202 getattr(self._next_node(src_parsed.body,
203 tempest_imports[-1]),
204 'lineno') == line_no or
205 line_no == len(src_lines)):
206 break
207 line_no += 1
208 import_snippet = '\n'.join((IMPORT_LINE, ''))
209 patcher.add_patch(source_path, import_snippet, line_no)
210
211 def get_tests(self):
212 """Get test methods with sources from base package with metadata"""
213 tests = {}
214 for module_name in self._modules_search():
215 tests[module_name] = {}
216 module = importlib.import_module(module_name)
217 source_path = '.'.join(
218 (os.path.splitext(module.__file__)[0], 'py')
219 )
220 with open(source_path, 'r') as f:
221 source = f.read()
222 tests[module_name]['source_path'] = source_path
223 tests[module_name]['tests'] = {}
224 source_parsed = ast.parse(source)
225 tests[module_name]['ast'] = source_parsed
226 tests[module_name]['import_valid'] = (
227 hasattr(module, DECORATOR_MODULE) and
228 inspect.ismodule(getattr(module, DECORATOR_MODULE))
229 )
230 test_cases = (node for node in source_parsed.body
231 if self._is_test_case(module, node))
232 for node in test_cases:
233 for subnode in filter(self._is_test_method, node.body):
234 test_name = '%s.%s' % (node.name, subnode.name)
235 tests[module_name]['tests'][test_name] = subnode
236 return tests
237
238 @staticmethod
239 def _filter_tests(function, tests):
240 """Filter tests with condition 'function(test_node) == True'"""
241 result = {}
242 for module_name in tests:
243 for test_name in tests[module_name]['tests']:
244 if function(module_name, test_name, tests):
245 if module_name not in result:
246 result[module_name] = {
247 'ast': tests[module_name]['ast'],
248 'source_path': tests[module_name]['source_path'],
249 'import_valid': tests[module_name]['import_valid'],
250 'tests': {}
251 }
252 result[module_name]['tests'][test_name] = \
253 tests[module_name]['tests'][test_name]
254 return result
255
256 def find_untagged(self, tests):
257 """Filter all tests without uuid in metadata"""
258 def check_uuid_in_meta(module_name, test_name, tests):
259 idempotent_id = self._get_idempotent_id(
260 tests[module_name]['tests'][test_name])
261 return not idempotent_id
262 return self._filter_tests(check_uuid_in_meta, tests)
263
264 def report_collisions(self, tests):
265 """Reports collisions if there are any. Returns true if
266 collisions exist.
267 """
268 uuids = {}
269
270 def report(module_name, test_name, tests):
271 test_uuid = self._get_idempotent_id(
272 tests[module_name]['tests'][test_name])
273 if not test_uuid:
274 return
275 if test_uuid in uuids:
276 error_str = "%s:%s\n uuid %s collision: %s<->%s\n%s:%s\n" % (
277 tests[module_name]['source_path'],
278 tests[module_name]['tests'][test_name].lineno,
279 test_uuid,
280 test_name,
281 uuids[test_uuid]['test_name'],
282 uuids[test_uuid]['source_path'],
283 uuids[test_uuid]['test_node'].lineno,
284 )
285 print(error_str)
286 return True
287 else:
288 uuids[test_uuid] = {
289 'module': module_name,
290 'test_name': test_name,
291 'test_node': tests[module_name]['tests'][test_name],
292 'source_path': tests[module_name]['source_path']
293 }
294 return bool(self._filter_tests(report, tests))
295
296 def report_untagged(self, tests):
297 """Reports untagged tests if there are any. Returns true if
298 untagged tests exist.
299 """
300 def report(module_name, test_name, tests):
301 error_str = "%s:%s\nmissing @test.idempotent_id('...')\n%s\n" % (
302 tests[module_name]['source_path'],
303 tests[module_name]['tests'][test_name].lineno,
304 test_name
305 )
306 print(error_str)
307 return True
308 return bool(self._filter_tests(report, tests))
309
310 def fix_tests(self, tests):
311 """Add uuids to all tests specified in tests and
312 fix it in source files
313 """
314 patcher = SourcePatcher()
315 for module_name in tests:
316 add_import_once = True
317 for test_name in tests[module_name]['tests']:
318 if not tests[module_name]['import_valid'] and add_import_once:
319 self._add_import_for_test_uuid(
320 patcher,
321 tests[module_name]['ast'],
322 tests[module_name]['source_path']
323 )
324 add_import_once = False
325 self._add_uuid_to_test(
326 patcher, tests[module_name]['tests'][test_name],
327 tests[module_name]['source_path'])
328 patcher.apply_patches()
329
330
331def run():
332 parser = argparse.ArgumentParser()
333 parser.add_argument('--package', action='store', dest='package',
334 default='tempest', type=str,
335 help='Package with tests')
336 parser.add_argument('--fix', action='store_true', dest='fix_tests',
337 help='Attempt to fix tests without UUIDs')
338 args = parser.parse_args()
339 sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
340 pkg = importlib.import_module(args.package)
341 checker = TestChecker(pkg)
342 errors = False
343 tests = checker.get_tests()
344 untagged = checker.find_untagged(tests)
345 errors = checker.report_collisions(tests) or errors
346 if args.fix_tests and untagged:
347 checker.fix_tests(untagged)
348 else:
349 errors = checker.report_untagged(untagged) or errors
350 if errors:
Chris Hoge7579c1a2015-02-26 14:12:15 -0800351 sys.exit("@test.idempotent_id existence and uniqueness checks failed\n"
352 "Run 'tox -v -euuidgen' to automatically fix tests with\n"
353 "missing @test.idempotent_id decorators.")
Chris Hoge296558c2015-02-19 00:29:49 -0600354
355if __name__ == '__main__':
356 run()