Migrate idempotent id decortor and tooling from tempest

This commit adds the idempotent id decorator and the corresponding
tooling from the tempest repo

At the time of this migration the current head on each tempest file
is:

 check_uuid.py: I1948c2d038d89af8dcfe69d01dcc49d024ae3210
 test.py: Id2bcabb97c61f68dbcee5afbfaf9d27c8f52264c
 test_decorators.py: I24f6e20cc8e310ba69fb23510795e235218abb2d

To get a complete history of the changes from tempest look up those
change ids from the tempest repo as a starting point.

Change-Id: Id0611ce8c1ccb319313959aa865dd91741295eb2
This commit is contained in:
Matthew Treinish 2015-08-25 17:45:07 -04:00
parent 61b7ffcaf8
commit f10b079dc8
No known key found for this signature in database
GPG Key ID: FD12A0F214C9E177
4 changed files with 415 additions and 0 deletions

View File

@ -26,6 +26,7 @@ packages =
[entry_points]
console_scripts =
skip-tracker = tempest_lib.cmd.skip_tracker:main
check-uuid = tempest_lib.cmd.check_uuid:run
[build_sphinx]
source-dir = doc/source

357
tempest_lib/cmd/check_uuid.py Executable file
View File

@ -0,0 +1,357 @@
#!/usr/bin/env python
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import argparse
import ast
import importlib
import inspect
import os
import sys
import unittest
import urllib
import uuid
DECORATOR_MODULE = 'test'
DECORATOR_NAME = 'idempotent_id'
DECORATOR_IMPORT = 'tempest.%s' % DECORATOR_MODULE
IMPORT_LINE = 'from tempest import %s' % DECORATOR_MODULE
DECORATOR_TEMPLATE = "@%s.%s('%%s')" % (DECORATOR_MODULE,
DECORATOR_NAME)
UNIT_TESTS_EXCLUDE = 'tempest.tests'
class SourcePatcher(object):
""""Lazy patcher for python source files"""
def __init__(self):
self.source_files = None
self.patches = None
self.clear()
def clear(self):
"""Clear inner state"""
self.source_files = {}
self.patches = {}
@staticmethod
def _quote(s):
return urllib.quote(s)
@staticmethod
def _unquote(s):
return urllib.unquote(s)
def add_patch(self, filename, patch, line_no):
"""Add lazy patch"""
if filename not in self.source_files:
with open(filename) as f:
self.source_files[filename] = self._quote(f.read())
patch_id = str(uuid.uuid4())
if not patch.endswith('\n'):
patch += '\n'
self.patches[patch_id] = self._quote(patch)
lines = self.source_files[filename].split(self._quote('\n'))
lines[line_no - 1] = ''.join(('{%s:s}' % patch_id, lines[line_no - 1]))
self.source_files[filename] = self._quote('\n').join(lines)
def _save_changes(self, filename, source):
print('%s fixed' % filename)
with open(filename, 'w') as f:
f.write(source)
def apply_patches(self):
"""Apply all patches"""
for filename in self.source_files:
patched_source = self._unquote(
self.source_files[filename].format(**self.patches)
)
self._save_changes(filename, patched_source)
self.clear()
class TestChecker(object):
def __init__(self, package):
self.package = package
self.base_path = os.path.abspath(os.path.dirname(package.__file__))
def _path_to_package(self, path):
relative_path = path[len(self.base_path) + 1:]
if relative_path:
return '.'.join((self.package.__name__,) +
tuple(relative_path.split('/')))
else:
return self.package.__name__
def _modules_search(self):
"""Recursive search for python modules in base package"""
modules = []
for root, dirs, files in os.walk(self.base_path):
if not os.path.exists(os.path.join(root, '__init__.py')):
continue
root_package = self._path_to_package(root)
for item in files:
if item.endswith('.py'):
module_name = '.'.join((root_package,
os.path.splitext(item)[0]))
if not module_name.startswith(UNIT_TESTS_EXCLUDE):
modules.append(module_name)
return modules
@staticmethod
def _get_idempotent_id(test_node):
"""Return key-value dict with all metadata from @test.idempotent_id"""
idempotent_id = None
for decorator in test_node.decorator_list:
if (hasattr(decorator, 'func') and
hasattr(decorator.func, 'attr') and
decorator.func.attr == DECORATOR_NAME and
hasattr(decorator.func, 'value') and
decorator.func.value.id == DECORATOR_MODULE):
for arg in decorator.args:
idempotent_id = ast.literal_eval(arg)
return idempotent_id
@staticmethod
def _is_decorator(line):
return line.strip().startswith('@')
@staticmethod
def _is_def(line):
return line.strip().startswith('def ')
def _add_uuid_to_test(self, patcher, test_node, source_path):
with open(source_path) as src:
src_lines = src.read().split('\n')
lineno = test_node.lineno
insert_position = lineno
while True:
if (self._is_def(src_lines[lineno - 1]) or
(self._is_decorator(src_lines[lineno - 1]) and
(DECORATOR_TEMPLATE.split('(')[0] <=
src_lines[lineno - 1].strip().split('(')[0]))):
insert_position = lineno
break
lineno += 1
patcher.add_patch(
source_path,
' ' * test_node.col_offset + DECORATOR_TEMPLATE % uuid.uuid4(),
insert_position
)
@staticmethod
def _is_test_case(module, node):
if (node.__class__ is ast.ClassDef and
hasattr(module, node.name) and
inspect.isclass(getattr(module, node.name))):
return issubclass(getattr(module, node.name), unittest.TestCase)
@staticmethod
def _is_test_method(node):
return (node.__class__ is ast.FunctionDef
and node.name.startswith('test_'))
@staticmethod
def _next_node(body, node):
if body.index(node) < len(body):
return body[body.index(node) + 1]
@staticmethod
def _import_name(node):
if type(node) == ast.Import:
return node.names[0].name
elif type(node) == ast.ImportFrom:
return '%s.%s' % (node.module, node.names[0].name)
def _add_import_for_test_uuid(self, patcher, src_parsed, source_path):
with open(source_path) as f:
src_lines = f.read().split('\n')
line_no = 0
tempest_imports = [node for node in src_parsed.body
if self._import_name(node) and
'tempest.' in self._import_name(node)]
if not tempest_imports:
import_snippet = '\n'.join(('', IMPORT_LINE, ''))
else:
for node in tempest_imports:
if self._import_name(node) < DECORATOR_IMPORT:
continue
else:
line_no = node.lineno
import_snippet = IMPORT_LINE
break
else:
line_no = tempest_imports[-1].lineno
while True:
if (not src_lines[line_no - 1] or
getattr(self._next_node(src_parsed.body,
tempest_imports[-1]),
'lineno') == line_no or
line_no == len(src_lines)):
break
line_no += 1
import_snippet = '\n'.join((IMPORT_LINE, ''))
patcher.add_patch(source_path, import_snippet, line_no)
def get_tests(self):
"""Get test methods with sources from base package with metadata"""
tests = {}
for module_name in self._modules_search():
tests[module_name] = {}
module = importlib.import_module(module_name)
source_path = '.'.join(
(os.path.splitext(module.__file__)[0], 'py')
)
with open(source_path, 'r') as f:
source = f.read()
tests[module_name]['source_path'] = source_path
tests[module_name]['tests'] = {}
source_parsed = ast.parse(source)
tests[module_name]['ast'] = source_parsed
tests[module_name]['import_valid'] = (
hasattr(module, DECORATOR_MODULE) and
inspect.ismodule(getattr(module, DECORATOR_MODULE))
)
test_cases = (node for node in source_parsed.body
if self._is_test_case(module, node))
for node in test_cases:
for subnode in filter(self._is_test_method, node.body):
test_name = '%s.%s' % (node.name, subnode.name)
tests[module_name]['tests'][test_name] = subnode
return tests
@staticmethod
def _filter_tests(function, tests):
"""Filter tests with condition 'function(test_node) == True'"""
result = {}
for module_name in tests:
for test_name in tests[module_name]['tests']:
if function(module_name, test_name, tests):
if module_name not in result:
result[module_name] = {
'ast': tests[module_name]['ast'],
'source_path': tests[module_name]['source_path'],
'import_valid': tests[module_name]['import_valid'],
'tests': {}
}
result[module_name]['tests'][test_name] = \
tests[module_name]['tests'][test_name]
return result
def find_untagged(self, tests):
"""Filter all tests without uuid in metadata"""
def check_uuid_in_meta(module_name, test_name, tests):
idempotent_id = self._get_idempotent_id(
tests[module_name]['tests'][test_name])
return not idempotent_id
return self._filter_tests(check_uuid_in_meta, tests)
def report_collisions(self, tests):
"""Reports collisions if there are any
Returns true if collisions exist.
"""
uuids = {}
def report(module_name, test_name, tests):
test_uuid = self._get_idempotent_id(
tests[module_name]['tests'][test_name])
if not test_uuid:
return
if test_uuid in uuids:
error_str = "%s:%s\n uuid %s collision: %s<->%s\n%s:%s" % (
tests[module_name]['source_path'],
tests[module_name]['tests'][test_name].lineno,
test_uuid,
test_name,
uuids[test_uuid]['test_name'],
uuids[test_uuid]['source_path'],
uuids[test_uuid]['test_node'].lineno,
)
print(error_str)
print("cannot automatically resolve the collision, please "
"manually remove the duplicate value on the new test.")
return True
else:
uuids[test_uuid] = {
'module': module_name,
'test_name': test_name,
'test_node': tests[module_name]['tests'][test_name],
'source_path': tests[module_name]['source_path']
}
return bool(self._filter_tests(report, tests))
def report_untagged(self, tests):
"""Reports untagged tests if there are any
Returns true if untagged tests exist.
"""
def report(module_name, test_name, tests):
error_str = "%s:%s\nmissing @test.idempotent_id('...')\n%s\n" % (
tests[module_name]['source_path'],
tests[module_name]['tests'][test_name].lineno,
test_name
)
print(error_str)
return True
return bool(self._filter_tests(report, tests))
def fix_tests(self, tests):
"""Add uuids to all specified in tests and fix it in source files"""
patcher = SourcePatcher()
for module_name in tests:
add_import_once = True
for test_name in tests[module_name]['tests']:
if not tests[module_name]['import_valid'] and add_import_once:
self._add_import_for_test_uuid(
patcher,
tests[module_name]['ast'],
tests[module_name]['source_path']
)
add_import_once = False
self._add_uuid_to_test(
patcher, tests[module_name]['tests'][test_name],
tests[module_name]['source_path'])
patcher.apply_patches()
def run():
parser = argparse.ArgumentParser()
parser.add_argument('--package', action='store', dest='package',
default='tempest', type=str,
help='Package with tests')
parser.add_argument('--fix', action='store_true', dest='fix_tests',
help='Attempt to fix tests without UUIDs')
args = parser.parse_args()
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
pkg = importlib.import_module(args.package)
checker = TestChecker(pkg)
errors = False
tests = checker.get_tests()
untagged = checker.find_untagged(tests)
errors = checker.report_collisions(tests) or errors
if args.fix_tests and untagged:
checker.fix_tests(untagged)
else:
errors = checker.report_untagged(untagged) or errors
if errors:
sys.exit("@test.idempotent_id existence and uniqueness checks failed\n"
"Run 'tox -v -euuidgen' to automatically fix tests with\n"
"missing @test.idempotent_id decorators.")
if __name__ == '__main__':
run()

View File

@ -13,7 +13,9 @@
# under the License.
import functools
import uuid
import six
import testtools
@ -40,3 +42,20 @@ def skip_because(*args, **kwargs):
return f(self, *func_args, **func_kwargs)
return wrapper
return decorator
def idempotent_id(id):
"""Stub for metadata decorator"""
if not isinstance(id, six.string_types):
raise TypeError('Test idempotent_id must be string not %s'
'' % type(id).__name__)
uuid.UUID(id)
def decorator(f):
f = testtools.testcase.attr('id-%s' % id)(f)
if f.__doc__:
f.__doc__ = 'Test idempotent id: %s\n%s' % (id, f.__doc__)
else:
f.__doc__ = 'Test idempotent id: %s' % id
return f
return decorator

View File

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import uuid
import testtools
from tempest_lib import base as test
@ -57,3 +59,39 @@ class TestSkipBecauseDecorator(base.TestCase):
"""Raise ValueError if with an invalid bug number"""
self.assertRaises(ValueError, self._test_skip_because_helper,
bug='critical_bug')
class TestIdempotentIdDecorator(base.TestCase):
def _test_helper(self, _id, **decorator_args):
@decorators.idempotent_id(_id)
def foo():
"""Docstring"""
pass
return foo
def _test_helper_without_doc(self, _id, **decorator_args):
@decorators.idempotent_id(_id)
def foo():
pass
return foo
def test_positive(self):
_id = str(uuid.uuid4())
foo = self._test_helper(_id)
self.assertIn('id-%s' % _id, getattr(foo, '__testtools_attrs'))
self.assertTrue(foo.__doc__.startswith('Test idempotent id: %s' % _id))
def test_positive_without_doc(self):
_id = str(uuid.uuid4())
foo = self._test_helper_without_doc(_id)
self.assertTrue(foo.__doc__.startswith('Test idempotent id: %s' % _id))
def test_idempotent_id_not_str(self):
_id = 42
self.assertRaises(TypeError, self._test_helper, _id)
def test_idempotent_id_not_valid_uuid(self):
_id = '42'
self.assertRaises(ValueError, self._test_helper, _id)