Marc Koderer | 07a5a6f | 2015-03-16 15:14:37 +0100 | [diff] [blame^] | 1 | #!/usr/bin/env python |
| 2 | |
Chris Hoge | 296558c | 2015-02-19 00:29:49 -0600 | [diff] [blame] | 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 | |
| 17 | import argparse |
| 18 | import ast |
| 19 | import importlib |
| 20 | import inspect |
| 21 | import os |
| 22 | import sys |
| 23 | import unittest |
| 24 | import urllib |
| 25 | import uuid |
| 26 | |
| 27 | DECORATOR_MODULE = 'test' |
| 28 | DECORATOR_NAME = 'idempotent_id' |
| 29 | DECORATOR_IMPORT = 'tempest.%s' % DECORATOR_MODULE |
| 30 | IMPORT_LINE = 'from tempest import %s' % DECORATOR_MODULE |
| 31 | DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE, |
| 32 | DECORATOR_NAME) |
Chris Hoge | 7579c1a | 2015-02-26 14:12:15 -0800 | [diff] [blame] | 33 | UNIT_TESTS_EXCLUDE = 'tempest.tests' |
Chris Hoge | 296558c | 2015-02-19 00:29:49 -0600 | [diff] [blame] | 34 | |
| 35 | |
| 36 | class SourcePatcher(object): |
| 37 | |
| 38 | """"Lazy patcher for python source files""" |
| 39 | |
| 40 | def __init__(self): |
| 41 | self.source_files = None |
| 42 | self.patches = None |
| 43 | self.clear() |
| 44 | |
| 45 | def clear(self): |
| 46 | """Clear inner state""" |
| 47 | self.source_files = {} |
| 48 | self.patches = {} |
| 49 | |
| 50 | @staticmethod |
| 51 | def _quote(s): |
| 52 | return urllib.quote(s) |
| 53 | |
| 54 | @staticmethod |
| 55 | def _unquote(s): |
| 56 | return urllib.unquote(s) |
| 57 | |
| 58 | def add_patch(self, filename, patch, line_no): |
| 59 | """Add lazy patch""" |
| 60 | if filename not in self.source_files: |
| 61 | with open(filename) as f: |
| 62 | self.source_files[filename] = self._quote(f.read()) |
| 63 | patch_id = str(uuid.uuid4()) |
| 64 | if not patch.endswith('\n'): |
| 65 | patch += '\n' |
| 66 | self.patches[patch_id] = self._quote(patch) |
| 67 | lines = self.source_files[filename].split(self._quote('\n')) |
| 68 | lines[line_no - 1] = ''.join(('{%s:s}' % patch_id, lines[line_no - 1])) |
| 69 | self.source_files[filename] = self._quote('\n').join(lines) |
| 70 | |
| 71 | def _save_changes(self, filename, source): |
| 72 | print('%s fixed' % filename) |
| 73 | with open(filename, 'w') as f: |
| 74 | f.write(source) |
| 75 | |
| 76 | def apply_patches(self): |
| 77 | """Apply all patches""" |
| 78 | for filename in self.source_files: |
| 79 | patched_source = self._unquote( |
| 80 | self.source_files[filename].format(**self.patches) |
| 81 | ) |
| 82 | self._save_changes(filename, patched_source) |
| 83 | self.clear() |
| 84 | |
| 85 | |
| 86 | class TestChecker(object): |
| 87 | |
| 88 | def __init__(self, package): |
| 89 | self.package = package |
| 90 | self.base_path = os.path.abspath(os.path.dirname(package.__file__)) |
| 91 | |
| 92 | def _path_to_package(self, path): |
| 93 | relative_path = path[len(self.base_path) + 1:] |
| 94 | if relative_path: |
| 95 | return '.'.join((self.package.__name__,) + |
| 96 | tuple(relative_path.split('/'))) |
| 97 | else: |
| 98 | return self.package.__name__ |
| 99 | |
| 100 | def _modules_search(self): |
| 101 | """Recursive search for python modules in base package""" |
| 102 | modules = [] |
| 103 | for root, dirs, files in os.walk(self.base_path): |
| 104 | if not os.path.exists(os.path.join(root, '__init__.py')): |
| 105 | continue |
| 106 | root_package = self._path_to_package(root) |
| 107 | for item in files: |
| 108 | if item.endswith('.py'): |
Chris Hoge | 7579c1a | 2015-02-26 14:12:15 -0800 | [diff] [blame] | 109 | module_name = '.'.join((root_package, |
| 110 | os.path.splitext(item)[0])) |
| 111 | if not module_name.startswith(UNIT_TESTS_EXCLUDE): |
| 112 | modules.append(module_name) |
Chris Hoge | 296558c | 2015-02-19 00:29:49 -0600 | [diff] [blame] | 113 | return modules |
| 114 | |
| 115 | @staticmethod |
| 116 | def _get_idempotent_id(test_node): |
| 117 | """ |
| 118 | Return key-value dict with all metadata from @test.idempotent_id |
| 119 | decorators for test method |
| 120 | """ |
| 121 | idempotent_id = None |
| 122 | for decorator in test_node.decorator_list: |
| 123 | if (hasattr(decorator, 'func') and |
Marc Koderer | fb199c0 | 2015-03-16 11:53:44 +0100 | [diff] [blame] | 124 | 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): |
Chris Hoge | 296558c | 2015-02-19 00:29:49 -0600 | [diff] [blame] | 128 | 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): |
| 168 | return (node.__class__ is ast.FunctionDef |
| 169 | and node.name.startswith('test_')) |
| 170 | |
| 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): |
| 178 | if type(node) == ast.Import: |
| 179 | return node.names[0].name |
| 180 | elif type(node) == ast.ImportFrom: |
| 181 | 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): |
| 236 | test_name = '%s.%s' % (node.name, subnode.name) |
| 237 | tests[module_name]['tests'][test_name] = subnode |
| 238 | 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. Returns true if |
| 268 | collisions exist. |
| 269 | """ |
| 270 | uuids = {} |
| 271 | |
| 272 | def report(module_name, test_name, tests): |
| 273 | test_uuid = self._get_idempotent_id( |
| 274 | tests[module_name]['tests'][test_name]) |
| 275 | if not test_uuid: |
| 276 | return |
| 277 | if test_uuid in uuids: |
| 278 | error_str = "%s:%s\n uuid %s collision: %s<->%s\n%s:%s\n" % ( |
| 279 | tests[module_name]['source_path'], |
| 280 | tests[module_name]['tests'][test_name].lineno, |
| 281 | test_uuid, |
| 282 | test_name, |
| 283 | uuids[test_uuid]['test_name'], |
| 284 | uuids[test_uuid]['source_path'], |
| 285 | uuids[test_uuid]['test_node'].lineno, |
| 286 | ) |
| 287 | print(error_str) |
| 288 | return True |
| 289 | else: |
| 290 | uuids[test_uuid] = { |
| 291 | 'module': module_name, |
| 292 | 'test_name': test_name, |
| 293 | 'test_node': tests[module_name]['tests'][test_name], |
| 294 | 'source_path': tests[module_name]['source_path'] |
| 295 | } |
| 296 | return bool(self._filter_tests(report, tests)) |
| 297 | |
| 298 | def report_untagged(self, tests): |
| 299 | """Reports untagged tests if there are any. Returns true if |
| 300 | untagged tests exist. |
| 301 | """ |
| 302 | def report(module_name, test_name, tests): |
| 303 | error_str = "%s:%s\nmissing @test.idempotent_id('...')\n%s\n" % ( |
| 304 | tests[module_name]['source_path'], |
| 305 | tests[module_name]['tests'][test_name].lineno, |
| 306 | test_name |
| 307 | ) |
| 308 | print(error_str) |
| 309 | return True |
| 310 | return bool(self._filter_tests(report, tests)) |
| 311 | |
| 312 | def fix_tests(self, tests): |
| 313 | """Add uuids to all tests specified in tests and |
| 314 | fix it in source files |
| 315 | """ |
| 316 | patcher = SourcePatcher() |
| 317 | for module_name in tests: |
| 318 | add_import_once = True |
| 319 | for test_name in tests[module_name]['tests']: |
| 320 | if not tests[module_name]['import_valid'] and add_import_once: |
| 321 | self._add_import_for_test_uuid( |
| 322 | patcher, |
| 323 | tests[module_name]['ast'], |
| 324 | tests[module_name]['source_path'] |
| 325 | ) |
| 326 | add_import_once = False |
| 327 | self._add_uuid_to_test( |
| 328 | patcher, tests[module_name]['tests'][test_name], |
| 329 | tests[module_name]['source_path']) |
| 330 | patcher.apply_patches() |
| 331 | |
| 332 | |
| 333 | def run(): |
| 334 | parser = argparse.ArgumentParser() |
| 335 | parser.add_argument('--package', action='store', dest='package', |
| 336 | default='tempest', type=str, |
| 337 | help='Package with tests') |
| 338 | parser.add_argument('--fix', action='store_true', dest='fix_tests', |
| 339 | help='Attempt to fix tests without UUIDs') |
| 340 | args = parser.parse_args() |
| 341 | sys.path.append(os.path.join(os.path.dirname(__file__), '..')) |
| 342 | pkg = importlib.import_module(args.package) |
| 343 | checker = TestChecker(pkg) |
| 344 | errors = False |
| 345 | tests = checker.get_tests() |
| 346 | untagged = checker.find_untagged(tests) |
| 347 | errors = checker.report_collisions(tests) or errors |
| 348 | if args.fix_tests and untagged: |
| 349 | checker.fix_tests(untagged) |
| 350 | else: |
| 351 | errors = checker.report_untagged(untagged) or errors |
| 352 | if errors: |
Chris Hoge | 7579c1a | 2015-02-26 14:12:15 -0800 | [diff] [blame] | 353 | sys.exit("@test.idempotent_id existence and uniqueness checks failed\n" |
| 354 | "Run 'tox -v -euuidgen' to automatically fix tests with\n" |
| 355 | "missing @test.idempotent_id decorators.") |
Chris Hoge | 296558c | 2015-02-19 00:29:49 -0600 | [diff] [blame] | 356 | |
| 357 | if __name__ == '__main__': |
| 358 | run() |