Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 1 | #!/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 | |
| 17 | import argparse |
| 18 | import ast |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 19 | import contextlib |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 20 | import importlib |
| 21 | import inspect |
| 22 | import os |
| 23 | import sys |
| 24 | import unittest |
| 25 | import uuid |
| 26 | |
janonymous | 69413b9 | 2016-12-06 13:34:19 +0530 | [diff] [blame] | 27 | from oslo_utils import uuidutils |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 28 | import six.moves.urllib.parse as urlparse |
| 29 | |
Ken'ichi Ohmichi | ebbfd1c | 2017-01-27 16:37:00 -0800 | [diff] [blame] | 30 | DECORATOR_MODULE = 'decorators' |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 31 | DECORATOR_NAME = 'idempotent_id' |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 32 | DECORATOR_IMPORT = 'tempest.lib.%s' % DECORATOR_MODULE |
Jeremy Liu | 12afdb8 | 2017-04-01 19:32:26 +0800 | [diff] [blame] | 33 | IMPORT_LINE = 'from tempest.lib import %s' % DECORATOR_MODULE |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 34 | DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE, |
| 35 | DECORATOR_NAME) |
| 36 | UNIT_TESTS_EXCLUDE = 'tempest.tests' |
| 37 | |
| 38 | |
| 39 | class 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()) |
janonymous | 69413b9 | 2016-12-06 13:34:19 +0530 | [diff] [blame] | 66 | patch_id = uuidutils.generate_uuid() |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 67 | 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 yunxian | 149c983 | 2016-09-26 16:13:13 +0800 | [diff] [blame] | 74 | @staticmethod |
| 75 | def _save_changes(filename, source): |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 76 | 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 | |
| 90 | class 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 Ressi | 2d6bcaa | 2018-04-11 12:37:36 +0200 | [diff] [blame] | 107 | for root, _, files in os.walk(self.base_path): |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 108 | 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, |
afazekas | 40fcb9b | 2019-03-08 11:25:11 +0100 | [diff] [blame] | 114 | os.path.splitext(item)[0])) |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 115 | 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 Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 121 | "Return key-value dict with metadata from @decorators.idempotent_id" |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 122 | idempotent_id = None |
| 123 | for decorator in test_node.decorator_list: |
| 124 | if (hasattr(decorator, 'func') and |
Federico Ressi | 2d6bcaa | 2018-04-11 12:37:36 +0200 | [diff] [blame] | 125 | 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 Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 129 | 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 Ressi | 2d6bcaa | 2018-04-11 12:37:36 +0200 | [diff] [blame] | 169 | return (node.__class__ is ast.FunctionDef and |
| 170 | node.name.startswith('test_')) |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 171 | |
| 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 Palm | ecd2ec0 | 2016-02-25 09:38:36 -0600 | [diff] [blame] | 179 | if isinstance(node, ast.Import): |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 180 | return node.names[0].name |
Brandon Palm | ecd2ec0 | 2016-02-25 09:38:36 -0600 | [diff] [blame] | 181 | elif isinstance(node, ast.ImportFrom): |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 182 | return '%s.%s' % (node.module, node.names[0].name) |
| 183 | |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 184 | @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 Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 226 | def _add_import_for_test_uuid(self, patcher, src_parsed, source_path): |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 227 | import_list = [node for node in src_parsed.body |
zhufl | 3ead998 | 2020-11-19 14:39:04 +0800 | [diff] [blame] | 228 | if isinstance(node, (ast.Import, ast.ImportFrom))] |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 229 | |
| 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 Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 237 | if self._import_name(node) and |
| 238 | 'tempest.' in self._import_name(node)] |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 239 | |
| 240 | for node in tempest_imports: |
| 241 | if self._import_name(node) < DECORATOR_IMPORT: |
| 242 | continue |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 243 | else: |
lkuchlan | c8b966f | 2020-01-07 12:53:55 +0200 | [diff] [blame] | 244 | 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 Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 302 | 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 Riedemann | 91d9242 | 2019-01-29 16:19:49 -0500 | [diff] [blame] | 327 | test_name = '%s.%s' % (node.name, subnode.name) |
| 328 | tests[module_name]['tests'][test_name] = subnode |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 329 | 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 Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 398 | error_str = ("%s:%s\nmissing @decorators.idempotent_id" |
| 399 | "('...')\n%s\n") % ( |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 400 | 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 | |
| 427 | def 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 Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 447 | sys.exit("@decorators.idempotent_id existence and uniqueness checks " |
| 448 | "failed\n" |
Hai Shi | 6f52fc5 | 2017-04-03 21:17:37 +0800 | [diff] [blame] | 449 | "Run 'tox -v -e uuidgen' to automatically fix tests with\n" |
Ken'ichi Ohmichi | 8a08211 | 2017-03-06 16:03:17 -0800 | [diff] [blame] | 450 | "missing @decorators.idempotent_id decorators.") |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 451 | |
Stephen Finucane | 7f4a621 | 2018-07-06 13:58:21 +0100 | [diff] [blame] | 452 | |
Matthew Treinish | 9e26ca8 | 2016-02-23 11:43:20 -0500 | [diff] [blame] | 453 | if __name__ == '__main__': |
| 454 | run() |