blob: ff09671dc24a61054cf0127891344470cbe8efe1 [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
lkuchlanc8b966f2020-01-07 12:53:55 +020019import contextlib
Matthew Treinish9e26ca82016-02-23 11:43:20 -050020import importlib
21import inspect
22import os
23import sys
24import unittest
25import uuid
26
janonymous69413b92016-12-06 13:34:19 +053027from oslo_utils import uuidutils
Matthew Treinish9e26ca82016-02-23 11:43:20 -050028import six.moves.urllib.parse as urlparse
29
Ken'ichi Ohmichiebbfd1c2017-01-27 16:37:00 -080030DECORATOR_MODULE = 'decorators'
Matthew Treinish9e26ca82016-02-23 11:43:20 -050031DECORATOR_NAME = 'idempotent_id'
lkuchlanc8b966f2020-01-07 12:53:55 +020032DECORATOR_IMPORT = 'tempest.lib.%s' % DECORATOR_MODULE
Jeremy Liu12afdb82017-04-01 19:32:26 +080033IMPORT_LINE = 'from tempest.lib import %s' % DECORATOR_MODULE
Matthew Treinish9e26ca82016-02-23 11:43:20 -050034DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE,
35 DECORATOR_NAME)
36UNIT_TESTS_EXCLUDE = 'tempest.tests'
37
38
39class SourcePatcher(object):
40
41 """"Lazy patcher for python source files"""
42
43 def __init__(self):
44 self.source_files = None
45 self.patches = None
46 self.clear()
47
48 def clear(self):
49 """Clear inner state"""
50 self.source_files = {}
51 self.patches = {}
52
53 @staticmethod
54 def _quote(s):
55 return urlparse.quote(s)
56
57 @staticmethod
58 def _unquote(s):
59 return urlparse.unquote(s)
60
61 def add_patch(self, filename, patch, line_no):
62 """Add lazy patch"""
63 if filename not in self.source_files:
64 with open(filename) as f:
65 self.source_files[filename] = self._quote(f.read())
janonymous69413b92016-12-06 13:34:19 +053066 patch_id = uuidutils.generate_uuid()
Matthew Treinish9e26ca82016-02-23 11:43:20 -050067 if not patch.endswith('\n'):
68 patch += '\n'
69 self.patches[patch_id] = self._quote(patch)
70 lines = self.source_files[filename].split(self._quote('\n'))
71 lines[line_no - 1] = ''.join(('{%s:s}' % patch_id, lines[line_no - 1]))
72 self.source_files[filename] = self._quote('\n').join(lines)
73
guo yunxian149c9832016-09-26 16:13:13 +080074 @staticmethod
75 def _save_changes(filename, source):
Matthew Treinish9e26ca82016-02-23 11:43:20 -050076 print('%s fixed' % filename)
77 with open(filename, 'w') as f:
78 f.write(source)
79
80 def apply_patches(self):
81 """Apply all patches"""
82 for filename in self.source_files:
83 patched_source = self._unquote(
84 self.source_files[filename].format(**self.patches)
85 )
86 self._save_changes(filename, patched_source)
87 self.clear()
88
89
90class TestChecker(object):
91
92 def __init__(self, package):
93 self.package = package
94 self.base_path = os.path.abspath(os.path.dirname(package.__file__))
95
96 def _path_to_package(self, path):
97 relative_path = path[len(self.base_path) + 1:]
98 if relative_path:
99 return '.'.join((self.package.__name__,) +
100 tuple(relative_path.split('/')))
101 else:
102 return self.package.__name__
103
104 def _modules_search(self):
105 """Recursive search for python modules in base package"""
106 modules = []
Federico Ressi2d6bcaa2018-04-11 12:37:36 +0200107 for root, _, files in os.walk(self.base_path):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500108 if not os.path.exists(os.path.join(root, '__init__.py')):
109 continue
110 root_package = self._path_to_package(root)
111 for item in files:
112 if item.endswith('.py'):
113 module_name = '.'.join((root_package,
afazekas40fcb9b2019-03-08 11:25:11 +0100114 os.path.splitext(item)[0]))
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500115 if not module_name.startswith(UNIT_TESTS_EXCLUDE):
116 modules.append(module_name)
117 return modules
118
119 @staticmethod
120 def _get_idempotent_id(test_node):
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800121 "Return key-value dict with metadata from @decorators.idempotent_id"
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500122 idempotent_id = None
123 for decorator in test_node.decorator_list:
124 if (hasattr(decorator, 'func') and
Federico Ressi2d6bcaa2018-04-11 12:37:36 +0200125 hasattr(decorator.func, 'attr') and
126 decorator.func.attr == DECORATOR_NAME and
127 hasattr(decorator.func, 'value') and
128 decorator.func.value.id == DECORATOR_MODULE):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500129 for arg in decorator.args:
130 idempotent_id = ast.literal_eval(arg)
131 return idempotent_id
132
133 @staticmethod
134 def _is_decorator(line):
135 return line.strip().startswith('@')
136
137 @staticmethod
138 def _is_def(line):
139 return line.strip().startswith('def ')
140
141 def _add_uuid_to_test(self, patcher, test_node, source_path):
142 with open(source_path) as src:
143 src_lines = src.read().split('\n')
144 lineno = test_node.lineno
145 insert_position = lineno
146 while True:
147 if (self._is_def(src_lines[lineno - 1]) or
148 (self._is_decorator(src_lines[lineno - 1]) and
149 (DECORATOR_TEMPLATE.split('(')[0] <=
150 src_lines[lineno - 1].strip().split('(')[0]))):
151 insert_position = lineno
152 break
153 lineno += 1
154 patcher.add_patch(
155 source_path,
156 ' ' * test_node.col_offset + DECORATOR_TEMPLATE % uuid.uuid4(),
157 insert_position
158 )
159
160 @staticmethod
161 def _is_test_case(module, node):
162 if (node.__class__ is ast.ClassDef and
163 hasattr(module, node.name) and
164 inspect.isclass(getattr(module, node.name))):
165 return issubclass(getattr(module, node.name), unittest.TestCase)
166
167 @staticmethod
168 def _is_test_method(node):
Federico Ressi2d6bcaa2018-04-11 12:37:36 +0200169 return (node.__class__ is ast.FunctionDef and
170 node.name.startswith('test_'))
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500171
172 @staticmethod
173 def _next_node(body, node):
174 if body.index(node) < len(body):
175 return body[body.index(node) + 1]
176
177 @staticmethod
178 def _import_name(node):
Brandon Palmecd2ec02016-02-25 09:38:36 -0600179 if isinstance(node, ast.Import):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500180 return node.names[0].name
Brandon Palmecd2ec02016-02-25 09:38:36 -0600181 elif isinstance(node, ast.ImportFrom):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500182 return '%s.%s' % (node.module, node.names[0].name)
183
lkuchlanc8b966f2020-01-07 12:53:55 +0200184 @contextlib.contextmanager
185 def ignore_site_packages_paths(self):
186 """Removes site-packages directories from the sys.path
187
188 Source:
189 - StackOverflow: https://stackoverflow.com/questions/22195382/
190 - Author: https://stackoverflow.com/users/485844/
191 """
192
193 paths = sys.path
194 # remove all third-party paths
195 # so that only stdlib imports will succeed
196 sys.path = list(filter(
197 None,
198 filter(lambda i: 'site-packages' not in i, sys.path)
199 ))
200 yield
201 sys.path = paths
202
203 def is_std_lib(self, module):
204 """Checks whether the module is part of the stdlib or not
205
206 Source:
207 - StackOverflow: https://stackoverflow.com/questions/22195382/
208 - Author: https://stackoverflow.com/users/485844/
209 """
210
211 if module in sys.builtin_module_names:
212 return True
213
214 with self.ignore_site_packages_paths():
215 imported_module = sys.modules.pop(module, None)
216 try:
217 importlib.import_module(module)
218 except ImportError:
219 return False
220 else:
221 return True
222 finally:
223 if imported_module:
224 sys.modules[module] = imported_module
225
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500226 def _add_import_for_test_uuid(self, patcher, src_parsed, source_path):
lkuchlanc8b966f2020-01-07 12:53:55 +0200227 import_list = [node for node in src_parsed.body
zhufl3ead9982020-11-19 14:39:04 +0800228 if isinstance(node, (ast.Import, ast.ImportFrom))]
lkuchlanc8b966f2020-01-07 12:53:55 +0200229
230 if not import_list:
231 print("(WARNING) %s: The file is not valid as it does not contain "
232 "any import line! Therefore the import needed by "
233 "@decorators.idempotent_id is not added!" % source_path)
234 return
235
236 tempest_imports = [node for node in import_list
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500237 if self._import_name(node) and
238 'tempest.' in self._import_name(node)]
lkuchlanc8b966f2020-01-07 12:53:55 +0200239
240 for node in tempest_imports:
241 if self._import_name(node) < DECORATOR_IMPORT:
242 continue
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500243 else:
lkuchlanc8b966f2020-01-07 12:53:55 +0200244 line_no = node.lineno
245 break
246 else:
247 if tempest_imports:
248 line_no = tempest_imports[-1].lineno + 1
249
250 # Insert import line between existing tempest imports
251 if tempest_imports:
252 patcher.add_patch(source_path, IMPORT_LINE, line_no)
253 return
254
255 # Group space separated imports together
256 grouped_imports = {}
257 first_import_line = import_list[0].lineno
258 for idx, import_line in enumerate(import_list, first_import_line):
259 group_no = import_line.lineno - idx
260 group = grouped_imports.get(group_no, [])
261 group.append(import_line)
262 grouped_imports[group_no] = group
263
264 if len(grouped_imports) > 3:
265 print("(WARNING) %s: The file contains more than three import "
266 "groups! This is not valid according to the PEP8 "
267 "style guide. " % source_path)
268
269 # Divide grouped_imports into groupes based on PEP8 style guide
270 pep8_groups = {}
271 package_name = self.package.__name__.split(".")[0]
272 for key in grouped_imports:
273 module = self._import_name(grouped_imports[key][0]).split(".")[0]
274 if module.startswith(package_name):
275 group = pep8_groups.get('3rd_group', [])
276 pep8_groups['3rd_group'] = group + grouped_imports[key]
277 elif self.is_std_lib(module):
278 group = pep8_groups.get('1st_group', [])
279 pep8_groups['1st_group'] = group + grouped_imports[key]
280 else:
281 group = pep8_groups.get('2nd_group', [])
282 pep8_groups['2nd_group'] = group + grouped_imports[key]
283
284 for node in pep8_groups.get('2nd_group', []):
285 if self._import_name(node) < DECORATOR_IMPORT:
286 continue
287 else:
288 line_no = node.lineno
289 import_snippet = IMPORT_LINE
290 break
291 else:
292 if pep8_groups.get('2nd_group', []):
293 line_no = pep8_groups['2nd_group'][-1].lineno + 1
294 import_snippet = IMPORT_LINE
295 elif pep8_groups.get('1st_group', []):
296 line_no = pep8_groups['1st_group'][-1].lineno + 1
297 import_snippet = '\n' + IMPORT_LINE
298 else:
299 line_no = pep8_groups['3rd_group'][0].lineno
300 import_snippet = IMPORT_LINE + '\n\n'
301
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500302 patcher.add_patch(source_path, import_snippet, line_no)
303
304 def get_tests(self):
305 """Get test methods with sources from base package with metadata"""
306 tests = {}
307 for module_name in self._modules_search():
308 tests[module_name] = {}
309 module = importlib.import_module(module_name)
310 source_path = '.'.join(
311 (os.path.splitext(module.__file__)[0], 'py')
312 )
313 with open(source_path, 'r') as f:
314 source = f.read()
315 tests[module_name]['source_path'] = source_path
316 tests[module_name]['tests'] = {}
317 source_parsed = ast.parse(source)
318 tests[module_name]['ast'] = source_parsed
319 tests[module_name]['import_valid'] = (
320 hasattr(module, DECORATOR_MODULE) and
321 inspect.ismodule(getattr(module, DECORATOR_MODULE))
322 )
323 test_cases = (node for node in source_parsed.body
324 if self._is_test_case(module, node))
325 for node in test_cases:
326 for subnode in filter(self._is_test_method, node.body):
Matt Riedemann91d92422019-01-29 16:19:49 -0500327 test_name = '%s.%s' % (node.name, subnode.name)
328 tests[module_name]['tests'][test_name] = subnode
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500329 return tests
330
331 @staticmethod
332 def _filter_tests(function, tests):
333 """Filter tests with condition 'function(test_node) == True'"""
334 result = {}
335 for module_name in tests:
336 for test_name in tests[module_name]['tests']:
337 if function(module_name, test_name, tests):
338 if module_name not in result:
339 result[module_name] = {
340 'ast': tests[module_name]['ast'],
341 'source_path': tests[module_name]['source_path'],
342 'import_valid': tests[module_name]['import_valid'],
343 'tests': {}
344 }
345 result[module_name]['tests'][test_name] = \
346 tests[module_name]['tests'][test_name]
347 return result
348
349 def find_untagged(self, tests):
350 """Filter all tests without uuid in metadata"""
351 def check_uuid_in_meta(module_name, test_name, tests):
352 idempotent_id = self._get_idempotent_id(
353 tests[module_name]['tests'][test_name])
354 return not idempotent_id
355 return self._filter_tests(check_uuid_in_meta, tests)
356
357 def report_collisions(self, tests):
358 """Reports collisions if there are any
359
360 Returns true if collisions exist.
361 """
362 uuids = {}
363
364 def report(module_name, test_name, tests):
365 test_uuid = self._get_idempotent_id(
366 tests[module_name]['tests'][test_name])
367 if not test_uuid:
368 return
369 if test_uuid in uuids:
370 error_str = "%s:%s\n uuid %s collision: %s<->%s\n%s:%s" % (
371 tests[module_name]['source_path'],
372 tests[module_name]['tests'][test_name].lineno,
373 test_uuid,
374 test_name,
375 uuids[test_uuid]['test_name'],
376 uuids[test_uuid]['source_path'],
377 uuids[test_uuid]['test_node'].lineno,
378 )
379 print(error_str)
380 print("cannot automatically resolve the collision, please "
381 "manually remove the duplicate value on the new test.")
382 return True
383 else:
384 uuids[test_uuid] = {
385 'module': module_name,
386 'test_name': test_name,
387 'test_node': tests[module_name]['tests'][test_name],
388 'source_path': tests[module_name]['source_path']
389 }
390 return bool(self._filter_tests(report, tests))
391
392 def report_untagged(self, tests):
393 """Reports untagged tests if there are any
394
395 Returns true if untagged tests exist.
396 """
397 def report(module_name, test_name, tests):
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800398 error_str = ("%s:%s\nmissing @decorators.idempotent_id"
399 "('...')\n%s\n") % (
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500400 tests[module_name]['source_path'],
401 tests[module_name]['tests'][test_name].lineno,
402 test_name
403 )
404 print(error_str)
405 return True
406 return bool(self._filter_tests(report, tests))
407
408 def fix_tests(self, tests):
409 """Add uuids to all specified in tests and fix it in source files"""
410 patcher = SourcePatcher()
411 for module_name in tests:
412 add_import_once = True
413 for test_name in tests[module_name]['tests']:
414 if not tests[module_name]['import_valid'] and add_import_once:
415 self._add_import_for_test_uuid(
416 patcher,
417 tests[module_name]['ast'],
418 tests[module_name]['source_path']
419 )
420 add_import_once = False
421 self._add_uuid_to_test(
422 patcher, tests[module_name]['tests'][test_name],
423 tests[module_name]['source_path'])
424 patcher.apply_patches()
425
426
427def run():
428 parser = argparse.ArgumentParser()
429 parser.add_argument('--package', action='store', dest='package',
430 default='tempest', type=str,
431 help='Package with tests')
432 parser.add_argument('--fix', action='store_true', dest='fix_tests',
433 help='Attempt to fix tests without UUIDs')
434 args = parser.parse_args()
435 sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
436 pkg = importlib.import_module(args.package)
437 checker = TestChecker(pkg)
438 errors = False
439 tests = checker.get_tests()
440 untagged = checker.find_untagged(tests)
441 errors = checker.report_collisions(tests) or errors
442 if args.fix_tests and untagged:
443 checker.fix_tests(untagged)
444 else:
445 errors = checker.report_untagged(untagged) or errors
446 if errors:
Ken'ichi Ohmichi44f01272017-01-27 18:44:14 -0800447 sys.exit("@decorators.idempotent_id existence and uniqueness checks "
448 "failed\n"
Hai Shi6f52fc52017-04-03 21:17:37 +0800449 "Run 'tox -v -e uuidgen' to automatically fix tests with\n"
Ken'ichi Ohmichi8a082112017-03-06 16:03:17 -0800450 "missing @decorators.idempotent_id decorators.")
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500451
Stephen Finucane7f4a6212018-07-06 13:58:21 +0100452
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500453if __name__ == '__main__':
454 run()