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