import inspect
import json
import os
import re
import sys
from functools import wraps
__version__ = '0.8.0'
# These attributes will not conflict with any real python attribute
# They are added to the decorated test method and processed later
# by the `ddt` class decorator.
DATA_ATTR = '%values' # store the data the test must run with
FILE_ATTR = '%file_path' # store the path to JSON file
UNPACK_ATTR = '%unpack' # remember that we have to unpack values
[docs]def unpack(func):
"""
Method decorator to add unpack feature.
"""
setattr(func, UNPACK_ATTR, True)
return func
[docs]def data(*values):
"""
Method decorator to add to your test methods.
Should be added to methods of instances of ``unittest.TestCase``.
"""
def wrapper(func):
setattr(func, DATA_ATTR, values)
return func
return wrapper
[docs]def file_data(value):
"""
Method decorator to add to your test methods.
Should be added to methods of instances of ``unittest.TestCase``.
``value`` should be a path relative to the directory of the file
containing the decorated ``unittest.TestCase``. The file
should contain JSON encoded data, that can either be a list or a
dict.
In case of a list, each value in the list will correspond to one
test case, and the value will be concatenated to the test method
name.
In case of a dict, keys will be used as suffixes to the name of the
test case, and values will be fed as test data.
"""
def wrapper(func):
setattr(func, FILE_ATTR, value)
return func
return wrapper
[docs]def is_hash_randomized():
return (((sys.hexversion >= 0x02070300 and
sys.hexversion < 0x03000000) or
(sys.hexversion >= 0x03020300)) and
sys.flags.hash_randomization and
'PYTHONHASHSEED' not in os.environ)
[docs]def mk_test_name(name, value, index=0):
"""
Generate a new name for a test case.
It will take the original test name and append an ordinal index and a
string representation of the value, and convert the result into a valid
python identifier by replacing extraneous characters with ``_``.
If hash randomization is enabled (a feature available since 2.7.3/3.2.3
and enabled by default since 3.3) and a "non-trivial" value is passed
this will omit the name argument by default. Set `PYTHONHASHSEED`
to a fixed value before running tests in these cases to get the
names back consistently or use the `__name__` attribute on data values.
A "trivial" value is a plain scalar, or a tuple or list consisting
only of trivial values.
"""
# We avoid doing str(value) if all of the following hold:
#
# * Python version is 2.7.3 or newer (for 2 series) or 3.2.3 or
# newer (for 3 series). Also sys.flags.hash_randomization didn't
# exist before these.
# * sys.flags.hash_randomization is set to True
# * PYTHONHASHSEED is **not** defined in the environment
# * Given `value` argument is not a trivial scalar (None, str,
# int, float).
#
# Trivial scalar values are passed as is in all cases.
trivial_types = (type(None), bool, str, int, float)
try:
trivial_types += (unicode,)
except NameError:
pass
def is_trivial(value):
if isinstance(value, trivial_types):
return True
if isinstance(value, (list, tuple)):
return all(map(is_trivial, value))
return False
if is_hash_randomized() and not is_trivial(value):
return "{0}_{1}".format(name, index + 1)
try:
value = str(value)
except UnicodeEncodeError:
# fallback for python2
value = value.encode('ascii', 'backslashreplace')
test_name = "{0}_{1}_{2}".format(name, index + 1, value)
return re.sub('\W|^(?=\d)', '_', test_name)
[docs]def ddt(cls):
"""
Class decorator for subclasses of ``unittest.TestCase``.
Apply this decorator to the test case class, and then
decorate test methods with ``@data``.
For each method decorated with ``@data``, this will effectively create as
many methods as data items are passed as parameters to ``@data``.
The names of the test methods follow the pattern
``original_test_name_{ordinal}_{data}``. ``ordinal`` is the position of the
data argument, starting with 1.
For data we use a string representation of the data value converted into a
valid python identifier. If ``data.__name__`` exists, we use that instead.
For each method decorated with ``@file_data('test_data.json')``, the
decorator will try to load the test_data.json file located relative
to the python file containing the method that is decorated. It will,
for each ``test_name`` key create as many methods in the list of values
from the ``data`` key.
"""
def feed_data(func, new_name, *args, **kwargs):
"""
This internal method decorator feeds the test data item to the test.
"""
@wraps(func)
def wrapper(self):
return func(self, *args, **kwargs)
wrapper.__name__ = new_name
return wrapper
def add_test(test_name, func, *args, **kwargs):
"""
Add a test case to this class.
The test will be based on an existing function but will give it a new
name.
"""
setattr(cls, test_name, feed_data(func, test_name, *args, **kwargs))
def process_file_data(name, func, file_attr):
"""
Process the parameter in the `file_data` decorator.
"""
cls_path = os.path.abspath(inspect.getsourcefile(cls))
data_file_path = os.path.join(os.path.dirname(cls_path), file_attr)
def _raise_ve(*args):
raise ValueError("%s does not exist" % file_attr)
if os.path.exists(data_file_path) is False:
test_name = mk_test_name(name, "error")
add_test(test_name, _raise_ve, None)
else:
data = json.loads(open(data_file_path).read())
for i, elem in enumerate(data):
if isinstance(data, dict):
key, value = elem, data[elem]
test_name = mk_test_name(name, key, i)
elif isinstance(data, list):
value = elem
test_name = mk_test_name(name, value, i)
add_test(test_name, func, value)
for name, func in list(cls.__dict__.items()):
if hasattr(func, DATA_ATTR):
for i, v in enumerate(getattr(func, DATA_ATTR)):
test_name = mk_test_name(name, getattr(v, "__name__", v), i)
if hasattr(func, UNPACK_ATTR):
if isinstance(v, tuple) or isinstance(v, list):
add_test(test_name, func, *v)
else:
# unpack dictionary
add_test(test_name, func, **v)
else:
add_test(test_name, func, v)
delattr(cls, name)
elif hasattr(func, FILE_ATTR):
file_attr = getattr(func, FILE_ATTR)
process_file_data(name, func, file_attr)
delattr(cls, name)
return cls