blob: 283b10fb15672be5d2182a8d4f4f7ef1e67eb332 [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
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -080030# on all OpenStack projects because they runs check-uuid on their own gates.
Ken'ichi Ohmichiebbfd1c2017-01-27 16:37:00 -080031OLD_DECORATOR_MODULE = 'test'
32
33DECORATOR_MODULE = 'decorators'
Matthew Treinish9e26ca82016-02-23 11:43:20 -050034DECORATOR_NAME = 'idempotent_id'
35DECORATOR_IMPORT = 'tempest.%s' % DECORATOR_MODULE
36IMPORT_LINE = 'from tempest import %s' % DECORATOR_MODULE
37DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE,
38 DECORATOR_NAME)
39UNIT_TESTS_EXCLUDE = 'tempest.tests'
40
41
42class SourcePatcher(object):
43
44 """"Lazy patcher for python source files"""
45
46 def __init__(self):
47 self.source_files = None
48 self.patches = None
49 self.clear()
50
51 def clear(self):
52 """Clear inner state"""
53 self.source_files = {}
54 self.patches = {}
55
56 @staticmethod
57 def _quote(s):
58 return urlparse.quote(s)
59
60 @staticmethod
61 def _unquote(s):
62 return urlparse.unquote(s)
63
64 def add_patch(self, filename, patch, line_no):
65 """Add lazy patch"""
66 if filename not in self.source_files:
67 with open(filename) as f:
68 self.source_files[filename] = self._quote(f.read())
janonymous69413b92016-12-06 13:34:19 +053069 patch_id = uuidutils.generate_uuid()
Matthew Treinish9e26ca82016-02-23 11:43:20 -050070 if not patch.endswith('\n'):
71 patch += '\n'
72 self.patches[patch_id] = self._quote(patch)
73 lines = self.source_files[filename].split(self._quote('\n'))
74 lines[line_no - 1] = ''.join(('{%s:s}' % patch_id, lines[line_no - 1]))
75 self.source_files[filename] = self._quote('\n').join(lines)
76
guo yunxian149c9832016-09-26 16:13:13 +080077 @staticmethod
78 def _save_changes(filename, source):
Matthew Treinish9e26ca82016-02-23 11:43:20 -050079 print('%s fixed' % filename)
80 with open(filename, 'w') as f:
81 f.write(source)
82
83 def apply_patches(self):
84 """Apply all patches"""
85 for filename in self.source_files:
86 patched_source = self._unquote(
87 self.source_files[filename].format(**self.patches)
88 )
89 self._save_changes(filename, patched_source)
90 self.clear()
91
92
93class TestChecker(object):
94
95 def __init__(self, package):
96 self.package = package
97 self.base_path = os.path.abspath(os.path.dirname(package.__file__))
98
99 def _path_to_package(self, path):
100 relative_path = path[len(self.base_path) + 1:]
101 if relative_path:
102 return '.'.join((self.package.__name__,) +
103 tuple(relative_path.split('/')))
104 else:
105 return self.package.__name__
106
107 def _modules_search(self):
108 """Recursive search for python modules in base package"""
109 modules = []
110 for root, dirs, files in os.walk(self.base_path):
111 if not os.path.exists(os.path.join(root, '__init__.py')):
112 continue
113 root_package = self._path_to_package(root)
114 for item in files:
115 if item.endswith('.py'):
116 module_name = '.'.join((root_package,
117 os.path.splitext(item)[0]))
118 if not module_name.startswith(UNIT_TESTS_EXCLUDE):
119 modules.append(module_name)
120 return modules
121
122 @staticmethod
123 def _get_idempotent_id(test_node):
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800124 "Return key-value dict with metadata from @decorators.idempotent_id"
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500125 idempotent_id = None
126 for decorator in test_node.decorator_list:
127 if (hasattr(decorator, 'func') and
128 hasattr(decorator.func, 'attr') and
129 decorator.func.attr == DECORATOR_NAME and
130 hasattr(decorator.func, 'value') and
Ken'ichi Ohmichiebbfd1c2017-01-27 16:37:00 -0800131 (decorator.func.value.id == DECORATOR_MODULE or
132 decorator.func.value.id == OLD_DECORATOR_MODULE)):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500133 for arg in decorator.args:
134 idempotent_id = ast.literal_eval(arg)
135 return idempotent_id
136
137 @staticmethod
138 def _is_decorator(line):
139 return line.strip().startswith('@')
140
141 @staticmethod
142 def _is_def(line):
143 return line.strip().startswith('def ')
144
145 def _add_uuid_to_test(self, patcher, test_node, source_path):
146 with open(source_path) as src:
147 src_lines = src.read().split('\n')
148 lineno = test_node.lineno
149 insert_position = lineno
150 while True:
151 if (self._is_def(src_lines[lineno - 1]) or
152 (self._is_decorator(src_lines[lineno - 1]) and
153 (DECORATOR_TEMPLATE.split('(')[0] <=
154 src_lines[lineno - 1].strip().split('(')[0]))):
155 insert_position = lineno
156 break
157 lineno += 1
158 patcher.add_patch(
159 source_path,
160 ' ' * test_node.col_offset + DECORATOR_TEMPLATE % uuid.uuid4(),
161 insert_position
162 )
163
164 @staticmethod
165 def _is_test_case(module, node):
166 if (node.__class__ is ast.ClassDef and
167 hasattr(module, node.name) and
168 inspect.isclass(getattr(module, node.name))):
169 return issubclass(getattr(module, node.name), unittest.TestCase)
170
171 @staticmethod
172 def _is_test_method(node):
173 return (node.__class__ is ast.FunctionDef
174 and node.name.startswith('test_'))
175
176 @staticmethod
177 def _next_node(body, node):
178 if body.index(node) < len(body):
179 return body[body.index(node) + 1]
180
181 @staticmethod
182 def _import_name(node):
Brandon Palmecd2ec02016-02-25 09:38:36 -0600183 if isinstance(node, ast.Import):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500184 return node.names[0].name
Brandon Palmecd2ec02016-02-25 09:38:36 -0600185 elif isinstance(node, ast.ImportFrom):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500186 return '%s.%s' % (node.module, node.names[0].name)
187
188 def _add_import_for_test_uuid(self, patcher, src_parsed, source_path):
189 with open(source_path) as f:
190 src_lines = f.read().split('\n')
191 line_no = 0
192 tempest_imports = [node for node in src_parsed.body
193 if self._import_name(node) and
194 'tempest.' in self._import_name(node)]
195 if not tempest_imports:
196 import_snippet = '\n'.join(('', IMPORT_LINE, ''))
197 else:
198 for node in tempest_imports:
199 if self._import_name(node) < DECORATOR_IMPORT:
200 continue
201 else:
202 line_no = node.lineno
203 import_snippet = IMPORT_LINE
204 break
205 else:
206 line_no = tempest_imports[-1].lineno
207 while True:
208 if (not src_lines[line_no - 1] or
209 getattr(self._next_node(src_parsed.body,
210 tempest_imports[-1]),
211 'lineno') == line_no or
212 line_no == len(src_lines)):
213 break
214 line_no += 1
215 import_snippet = '\n'.join((IMPORT_LINE, ''))
216 patcher.add_patch(source_path, import_snippet, line_no)
217
218 def get_tests(self):
219 """Get test methods with sources from base package with metadata"""
220 tests = {}
221 for module_name in self._modules_search():
222 tests[module_name] = {}
223 module = importlib.import_module(module_name)
224 source_path = '.'.join(
225 (os.path.splitext(module.__file__)[0], 'py')
226 )
227 with open(source_path, 'r') as f:
228 source = f.read()
229 tests[module_name]['source_path'] = source_path
230 tests[module_name]['tests'] = {}
231 source_parsed = ast.parse(source)
232 tests[module_name]['ast'] = source_parsed
233 tests[module_name]['import_valid'] = (
234 hasattr(module, DECORATOR_MODULE) and
235 inspect.ismodule(getattr(module, DECORATOR_MODULE))
236 )
237 test_cases = (node for node in source_parsed.body
238 if self._is_test_case(module, node))
239 for node in test_cases:
240 for subnode in filter(self._is_test_method, node.body):
241 test_name = '%s.%s' % (node.name, subnode.name)
242 tests[module_name]['tests'][test_name] = subnode
243 return tests
244
245 @staticmethod
246 def _filter_tests(function, tests):
247 """Filter tests with condition 'function(test_node) == True'"""
248 result = {}
249 for module_name in tests:
250 for test_name in tests[module_name]['tests']:
251 if function(module_name, test_name, tests):
252 if module_name not in result:
253 result[module_name] = {
254 'ast': tests[module_name]['ast'],
255 'source_path': tests[module_name]['source_path'],
256 'import_valid': tests[module_name]['import_valid'],
257 'tests': {}
258 }
259 result[module_name]['tests'][test_name] = \
260 tests[module_name]['tests'][test_name]
261 return result
262
263 def find_untagged(self, tests):
264 """Filter all tests without uuid in metadata"""
265 def check_uuid_in_meta(module_name, test_name, tests):
266 idempotent_id = self._get_idempotent_id(
267 tests[module_name]['tests'][test_name])
268 return not idempotent_id
269 return self._filter_tests(check_uuid_in_meta, tests)
270
271 def report_collisions(self, tests):
272 """Reports collisions if there are any
273
274 Returns true if collisions exist.
275 """
276 uuids = {}
277
278 def report(module_name, test_name, tests):
279 test_uuid = self._get_idempotent_id(
280 tests[module_name]['tests'][test_name])
281 if not test_uuid:
282 return
283 if test_uuid in uuids:
284 error_str = "%s:%s\n uuid %s collision: %s<->%s\n%s:%s" % (
285 tests[module_name]['source_path'],
286 tests[module_name]['tests'][test_name].lineno,
287 test_uuid,
288 test_name,
289 uuids[test_uuid]['test_name'],
290 uuids[test_uuid]['source_path'],
291 uuids[test_uuid]['test_node'].lineno,
292 )
293 print(error_str)
294 print("cannot automatically resolve the collision, please "
295 "manually remove the duplicate value on the new test.")
296 return True
297 else:
298 uuids[test_uuid] = {
299 'module': module_name,
300 'test_name': test_name,
301 'test_node': tests[module_name]['tests'][test_name],
302 'source_path': tests[module_name]['source_path']
303 }
304 return bool(self._filter_tests(report, tests))
305
306 def report_untagged(self, tests):
307 """Reports untagged tests if there are any
308
309 Returns true if untagged tests exist.
310 """
311 def report(module_name, test_name, tests):
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800312 error_str = ("%s:%s\nmissing @decorators.idempotent_id"
313 "('...')\n%s\n") % (
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500314 tests[module_name]['source_path'],
315 tests[module_name]['tests'][test_name].lineno,
316 test_name
317 )
318 print(error_str)
319 return True
320 return bool(self._filter_tests(report, tests))
321
322 def fix_tests(self, tests):
323 """Add uuids to all specified in tests and fix it in source files"""
324 patcher = SourcePatcher()
325 for module_name in tests:
326 add_import_once = True
327 for test_name in tests[module_name]['tests']:
328 if not tests[module_name]['import_valid'] and add_import_once:
329 self._add_import_for_test_uuid(
330 patcher,
331 tests[module_name]['ast'],
332 tests[module_name]['source_path']
333 )
334 add_import_once = False
335 self._add_uuid_to_test(
336 patcher, tests[module_name]['tests'][test_name],
337 tests[module_name]['source_path'])
338 patcher.apply_patches()
339
340
341def run():
342 parser = argparse.ArgumentParser()
343 parser.add_argument('--package', action='store', dest='package',
344 default='tempest', type=str,
345 help='Package with tests')
346 parser.add_argument('--fix', action='store_true', dest='fix_tests',
347 help='Attempt to fix tests without UUIDs')
348 args = parser.parse_args()
349 sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
350 pkg = importlib.import_module(args.package)
351 checker = TestChecker(pkg)
352 errors = False
353 tests = checker.get_tests()
354 untagged = checker.find_untagged(tests)
355 errors = checker.report_collisions(tests) or errors
356 if args.fix_tests and untagged:
357 checker.fix_tests(untagged)
358 else:
359 errors = checker.report_untagged(untagged) or errors
360 if errors:
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800361 sys.exit("@decorators.idempotent_id existence and uniqueness checks "
362 "failed\n"
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500363 "Run 'tox -v -euuidgen' to automatically fix tests with\n"
364 "missing @test.idempotent_id decorators.")
365
366if __name__ == '__main__':
367 run()