# -*- coding: utf-8 -*-
#
# New BSD license
#
# Copyright (c) DR0ID
# This file is part of HG_pyweek_games
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the <organization> nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL DR0ID BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
TODO: module description
Inspired by ddt::
http://ddt.readthedocs.org/en/latest/index.html
https://github.com/txels/ddt
Versioning scheme based on: http://en.wikipedia.org/wiki/Versioning#Designating_development_stage
::
+-- api change, probably incompatible with older versions
| +-- enhancements but no api change
| |
major.minor[.build[.revision]]
| |
| +-|* x for x bugfixes
|
+-|* 0 for alpha (status)
|* 1 for beta (status)
|* 2 for release candidate
|* 3 for (public) release
.. versionchanged:: 0.9.0.0
initial version
"""
import string
import re
from functools import wraps
import collections
__version__ = '1.0.0.0'
# for easy comparison as in sys.version_info but digits only
__version_info__ = tuple([int(d) for d in __version__.split('.')])
__author__ = 'DR0ID'
__email__ = 'dr0iddr0id [at] googlemail [dot] com'
__copyright__ = "DR0ID @ 2014"
__credits__ = ["DR0ID"] # list of contributors
__maintainer__ = "DR0ID"
__license__ = "New BSD license"
# list of public visible parts of this module
__all__ = ["use_data_driven_testing_decorators", "test_case", "test_case_from_generator", "MatchType", "RESULT",
"EXPECTED_EXCEPTION", "TEST_NAME", "DESCRIPTION", "STRICT", "EXPECTED_MESSAGE", "MATCH_TYPE",
"FUNCTION_KWARGS"]
[docs]class MatchType(object):
"""
This is the MatchType enum.
* EXACT: matches the expected exception message exactly against the expected message
* CONTAINS: the expected exception message contains the expected message
* REGEX: the expected message is a regex expression that has to match with the expected exception message
* STARTS_WITH: the expected exception message starts with the expected message
"""
EXACT = 0
CONTAINS = 1
REGEX = 2
STARTS_WITH = 3
RESULT = 'result'
EXPECTED_EXCEPTION = 'expected_exception'
TEST_NAME = 'test_name'
DESCRIPTION = 'description'
STRICT = 'strict'
EXPECTED_MESSAGE = 'expected_message'
MATCH_TYPE = 'match_type'
FUNCTION_KWARGS = 'function_kwargs'
_DATA_TEST_CASE_ATTRIBUTE = "DDT_decorators_data_test_cases"
_GENERATOR_TEST_CASE_ATTRIBUTE = "DDT_decorators_generator_test_cases"
[docs]def use_data_driven_testing_decorators(cls):
"""
The class decorator. Enables to use the data driven test cases.
Used on subclasses of ``unittest.TestCase``.
:param cls:
:return: :raise AttributeError:
"""
for func_name, func in list(cls.__dict__.items()):
if hasattr(func, _DATA_TEST_CASE_ATTRIBUTE):
data_points = getattr(func, _DATA_TEST_CASE_ATTRIBUTE)
_add_new_methods(cls, func, func_name, data_points, _DATA_TEST_CASE_ATTRIBUTE)
elif hasattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE):
for generator, g_args, g_kwargs in getattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE):
_add_new_methods(cls, func, func_name, generator(*g_args, **g_kwargs), _GENERATOR_TEST_CASE_ATTRIBUTE)
return cls
[docs]def test_case(*args, **kwargs):
"""
The test case decorator.
:param args: arguments of the test method.
:param kwargs: additional parameters::
'result': 2
'description': the __doc__ string
'expected_exception': ZeroDivisionError
'expected_message': the expected message, e.g. from an exception
'match_type': MatchType.EXACT (default)
'test_name': name for test method, otherwise constructed from args e.g. def test_xxx() and test_name = YYY then the following method name is constructed: test_xxx_DDT_yyy()
'strict': if true, does not allow for other keywords in kwargs as the defined ones
'function_kwargs': the kwargs that are passed to the function, use like this: function_kwargs={'1':1, ...}
:return: the wrapper method (this is a decorator function)
:raise ValueError: if no arguments are provided.
"""
if not args:
raise ValueError("should have arguments")
_check_kwargs_if_strict(kwargs)
def wrapper(func):
"""
The wrapper function that adds the attribute with the arguments.
:param func:
:return:
"""
if not hasattr(func, _DATA_TEST_CASE_ATTRIBUTE):
setattr(func, _DATA_TEST_CASE_ATTRIBUTE, [])
test_cases = getattr(func, _DATA_TEST_CASE_ATTRIBUTE)
test_cases.append((args, kwargs))
return func
return wrapper
[docs]def test_case_from_generator(generator_function, *generator_args, **generator_kwargs):
"""
Data driven test cases from a generator function. This can be used to read values from a file.
:param generator_function: a generator function. It should be iterable.
It should generate a iterable sequence like (same arguments as for a test_case):
[(args, kwargs), (args, kwargs), ....]
:param generator_args: optional arguments for the generator function
:param generator_kwargs: optional kwargs for the generator function
:return: wrapped method
:raise ValueError: if generator function is not given
"""
if generator_function is None or not isinstance(generator_function, collections.Callable):
raise ValueError("generator_function should be callable and not None")
def wrapper(func):
"""
The wrapper function. Adds the _DATA_FILE_INFO attribute with its parameters to the wrapped function.
:param func: The function to be wrapped
:return: The same function with additional test case data.
"""
if not hasattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE):
setattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE, [])
generator_attr = getattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE)
generator_attr.append((generator_function, generator_args, generator_kwargs))
return func
return wrapper
def _check_kwargs_if_strict(kwargs):
strict = kwargs.get(STRICT, True)
for kwarg in list(kwargs.keys()):
if strict and kwarg not in [RESULT, EXPECTED_EXCEPTION, TEST_NAME, DESCRIPTION, STRICT,
EXPECTED_MESSAGE, MATCH_TYPE, FUNCTION_KWARGS]:
raise AttributeError("strict mode: unknown kwargs '{0}', should be one of {1}".format(kwarg,
[RESULT,
EXPECTED_EXCEPTION,
TEST_NAME,
DESCRIPTION, STRICT,
EXPECTED_MESSAGE,
MATCH_TYPE,
FUNCTION_KWARGS]))
def _feed_data(function, arguments, kwargs):
def _verify_methods_return_value(function_kwargs, self):
expected_result = kwargs[RESULT]
actual = function(self, *arguments, **function_kwargs)
# assertEqual
if not expected_result == actual:
raise AssertionError('expected: %r != actual: %r' % (expected_result, actual))
def _verify_method_call(function_kwargs, self):
# TODO: extract to method
try:
actual = function(self, *arguments, **function_kwargs)
except Exception as ex:
raise
if actual is not None:
raise UserWarning(
"method {0} returned a value but no 'result' keyword has been provided".format(
function.__name__))
def _check_expected_exception_message(e):
# assertions
if EXPECTED_MESSAGE in kwargs:
exp_msg = kwargs[EXPECTED_MESSAGE]
match_type = kwargs.get(MATCH_TYPE, MatchType.EXACT)
if match_type == MatchType.EXACT:
if exp_msg != str(e):
raise AssertionError("exception message '{0}' does not match exactly by expected message '{1}'",
str(e), exp_msg)
elif match_type == MatchType.CONTAINS:
if exp_msg not in str(e):
raise AssertionError("exception message '{0}' does not contain expected '{1}'", str(e), exp_msg)
elif match_type == MatchType.STARTS_WITH:
if not str(e).startswith(exp_msg):
raise AssertionError("exception message '{0}' does not start with '{1}'", str(e), exp_msg)
elif match_type == MatchType.REGEX:
flags = None
if len(exp_msg) == 2:
exp_msg, flags = exp_msg
elif len(exp_msg) > 2:
raise AssertionError(
"too many regex arguments, should be either regex string or (regex_string, flags)")
if flags is None:
m = re.search(exp_msg, str(e))
else:
m = re.search(exp_msg, str(e), flags)
if m is None:
raise AssertionError(
"exception message '{0}' is not matched by regex '{1}'".format(str(e), exp_msg))
else:
raise TypeError("Unknown match type {0}".format(match_type))
@wraps(function)
def wrapped_parametrized_test_method(self):
"""
The actual test method that is executed.
:param self: instance of the unittest class
:return: :raise AssertionError:
"""
function_kwargs = kwargs.get(FUNCTION_KWARGS, {})
expected_exception = kwargs.get(EXPECTED_EXCEPTION, None)
if expected_exception is None:
if RESULT in kwargs:
_verify_methods_return_value(function_kwargs, self)
else:
_verify_method_call(function_kwargs, self)
else:
try:
function(self, *arguments, **function_kwargs)
except expected_exception as ex:
_check_expected_exception_message(ex)
return
except Exception as ex:
raise AssertionError(
"{0} not raised by {1}, instead {2} was raised".format(expected_exception, function, ex))
return wrapped_parametrized_test_method
def _add_new_methods(_cls, _func, _name, _data_points, _attribute_name):
for the_arguments, kwargs in _data_points:
_check_kwargs_if_strict(kwargs)
method_name_extension = _create_method_name(kwargs.get(TEST_NAME, ""), the_arguments, kwargs)
test_method_name = "{0}_{1}".format(_name, method_name_extension)
if hasattr(_cls, test_method_name):
raise AttributeError("a method with name '{0}' already exists (use test_name='...' to override this".format(
test_method_name))
new_method = _feed_data(_func, the_arguments, kwargs)
setattr(_cls, test_method_name, new_method)
setattr(new_method, "__doc__", kwargs.get(DESCRIPTION, ""))
# remove attribute from function
delattr(_func, _attribute_name)
# remove replaced method from class
delattr(_cls, _name)
def _create_method_name(name, arguments, kwargs):
name_extension = "DDT_"
if name is "":
args_name = "args_{0}".format(arguments)
if kwargs:
args_name = "{0}_{1}".format(args_name, kwargs)
separator = "" if name is "" else "_"
generated_name = "{0}{1}{2}{3}".format(name_extension, name, separator, args_name)
else:
generated_name = name_extension + name
return _convert_to_valid_method_name(generated_name)
def _convert_to_valid_method_name(in_string):
valid_letters = string.ascii_letters + string.digits + '_'
replacements = ''
for c in in_string:
if c in valid_letters:
continue
replacements += c
out_string = in_string
for c in replacements:
out_string = out_string.replace(c, '_')
return out_string