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