From d2de3e2de92533d7a6c695b8f06cf0b23dc9af5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 12:22:38 +0200 Subject: [PATCH 01/17] Refactoring test name generation to make existing code more reusable --- ddt.py | 140 +++++++++++++++++++++++++++++++++--- test/test_ddt2_internals.py | 55 ++++++++++++++ 2 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 test/test_ddt2_internals.py diff --git a/ddt.py b/ddt.py index a084695..4f49255 100644 --- a/ddt.py +++ b/ddt.py @@ -70,14 +70,6 @@ def wrapper(func): return wrapper -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) - - def mk_test_name(name, value, index=0): """ Generate a new name for a test case. @@ -227,3 +219,135 @@ def ddt(cls): process_file_data(cls, name, func, file_attr) delattr(cls, name) return cls + + +# Test name generation + + +def is_hash_randomized(): + """ + Check whether hashes are randomized in the current Python version. + + See `convert_to_name` for details. + + """ + return (((sys.hexversion >= 0x02070300 and + sys.hexversion < 0x03000000) or + (sys.hexversion >= 0x03020300)) and + sys.flags.hash_randomization and + 'PYTHONHASHSEED' not in os.environ) + + +def trivial_types(): + """ + Return a tuple of types types that can be converted into valid + identifiers easily. + + """ + trivial_types = (type(None), bool, str, int, float) + + try: + trivial_types += (unicode,) + except NameError: + pass + + return trivial_types + + +def is_trivial(value): + """ + Check whether a value is of a trivial type w.r.t. `trivial_types()`. + + """ + if isinstance(value, trivial_types()): + return True + + if isinstance(value, (list, tuple)): + return all(map(is_trivial, value)) + + return False + + +def convert_to_name(value): + """ + Convert a value into a string that can safely be used in an attribute name. + + Returns a string representation of the value with all extraneous characters + replaced with ``_``. Sequences of underscores are reduced to one, leading + and trailing underscores are trimmed. + + Raises ValueError if it cannot convert the value (see the note below). + + Note: 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. + + if not is_trivial(value) and is_hash_randomized(): + raise ValueError("Cannot convert complex type: {0}", str(type(value))) + + try: + value = str(value) + except UnicodeEncodeError: + # fallback for python2 + value = value.encode('ascii', 'backslashreplace') + + value = re.sub('\W', '_', value) + return re.sub('_+', '_', value).strip('_') + + +def make_params_name(idx, name, value): + """ + Generate a name for a value in a parameters set. + + It will take the ordinal index of the value in the set and a name + constructed from one of the follwing items (with decreasing priority): + `name`, `value.__name__`, `value`. See also `convert_to_name`. + + If all of that fails, only the ordinal index is used. + """ + + if name is None: + name = getattr(value, '__name__', value) + + try: + name = convert_to_name(name) + return "{0}_{1}".format(idx, name) + + except ValueError: + pass + + return "{0}".format(idx) + + +def combine_names(name1, name2): + """ + Combine two names using two underscore characters ``__``. ``None`` can be + used as the identity. + + """ + if name2 is None: + return name1 + else: + if name1 is None: + return name2 + else: + return"{0}__{1}".format(name1, name2) diff --git a/test/test_ddt2_internals.py b/test/test_ddt2_internals.py new file mode 100644 index 0000000..d2ce2be --- /dev/null +++ b/test/test_ddt2_internals.py @@ -0,0 +1,55 @@ + +from unittest import TestCase + +import ddt + + +class TestMakeParamsName(TestCase): + + def test__unnamed_int(self): + self.assertEqual(ddt.make_params_name(3, None, 4), '3_4') + + def test__named_int(self): + self.assertEqual(ddt.make_params_name(3, 'myint', 4), '3_myint') + + def test__unnamed_string(self): + self.assertEqual(ddt.make_params_name(3, None, 'a'), '3_a') + + def test__named_string(self): + self.assertEqual( + ddt.make_params_name(3, 'mystring', 'a'), + '3_mystring' + ) + + def test__unnamed_array_of_strings(self): + self.assertEqual(ddt.make_params_name(3, None, ['a', 'b']), '3_a_b') + + def test__named_array_of_strings(self): + self.assertEqual( + ddt.make_params_name(3, 'myarray', ['a', 'b']), + '3_myarray' + ) + + def test__unnamed_complex_type(self): + if ddt.is_hash_randomized(): + self.assertEqual(ddt.make_params_name(3, None, Exception()), '3') + else: + self.assertEqual(ddt.make_params_name(3, None, Exception()), '3_') + + def test__unnamed_value_with__name__attribute(self): + class SampleInt(int): + pass + + v = SampleInt(1) + v.__name__ = 'custom name' + + self.assertEqual(ddt.make_params_name(3, None, v), '3_custom_name') + + def test__named_value_with__name__attribute(self): + class SampleInt(int): + pass + + v = SampleInt(1) + v.__name__ = 'custom name' + + self.assertEqual(ddt.make_params_name(3, 'myint', v), '3_myint') From 3cbb3b581413bae1d51943350b7fdb05e65547a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 12:31:57 +0200 Subject: [PATCH 02/17] Data structures for working with test parameters Representing items supplied by the data and file_data decorators as instances of classes Params and ParamsFailure will simplify implementation of multi-level unpacking (the unpack() method) and nested data and file_data decorators (combine two instances by summing them up). --- ddt.py | 116 +++++++++++++++++++++++++++++ test/test_ddt2_internals.py | 142 ++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/ddt.py b/ddt.py index 4f49255..42c6619 100644 --- a/ddt.py +++ b/ddt.py @@ -221,6 +221,122 @@ def ddt(cls): return cls +# Internal data structures + + +class ParamsFailure: + """ + FileParamsSet generates an instance of this class instead of instances of + Params in case it cannot load the data file. A fake test method is + generated instead of a regular one if it has an instance of this class + among its parameters. + + ParamsFailure supports the same interface as Params. + + More formally, instances of Params and ParamsFailure form a semigroup with + respect to addition (+) with ``Params(None, [], {}`` being a left identity. + The ``unpack()`` method is a homomorphism. + + Instances of ParamsFailure act almost as absorbing elements (0.X=0, X.0=0). + The left-most instance of ParamsFailure in a sum prevails, only the name + keeps updating. + + """ + + def __init__(self, name, reason): + self.name = name + self.reason = reason + + def unpack(self, N=1): + """ + Do nothing but return self (convenient for use in mappings). + + """ + return self + + def __add__(self, other): + """ + Return a sum of self and an instance of Param or ParamFailure. + + The sum is a new instance of ParamFailure with the `reason` from the + left operand and a name combining names of both operands. + + """ + new_name = combine_names(self.name, other.name) + return ParamsFailure(new_name, self.reason) + + +class Params: + """ + Instances of Params form a semigroup with respect to addition. + ``Params(None, [], {})`` constitutes the identity. The ``unpack()`` + function is a homomorphism. + + """ + + def __init__(self, name, args, kwargs): + self.name = name + self.args = args + self.kwargs = kwargs + + def unpack(self, N=1): + """ + Recursively unpack positional arguments `N`-times. + + """ + while N > 0: + self.unpack_one_level() + N = N - 1 + return self + + def unpack_one_level(self): + """ + Unpack one level of positional arguments. + + Members of lists and tuples replace its parents as new positional + arguments. Members of dicts are used to update keyword arguments. Other + positional arguments are left intact. + + """ + new_args = [] + + for value in self.args: + if isinstance(value, list) or isinstance(value, tuple): + new_args.extend(value) + elif isinstance(value, dict): + self.kwargs.update(value) + else: + new_args.append(value) + + self.args = new_args + + def __add__(self, other): + """ + Return a sum of self and an instance of Param or ParamFailure. + + If combined with Params, positional arguments in the right operand are + concatenated to positional arguments in the left operand and keywrd + arguments in the right operand are used to update keyword arguments in + the left operand. + + Note: Addition modifies the left-most instance of Params in a sum. This + is OK for the expected use where a fresh identity Params(None, [], {}) + is supplied as the leftmost operand. + + If combined with ParamsFailure, a new instance of ParamsFailure is + returned. + """ + new_name = combine_names(self.name, other.name) + + if isinstance(other, ParamsFailure): + return ParamsFailure(new_name, other.reason) + + self.name = new_name + self.args.extend(other.args) + self.kwargs.update(other.kwargs) + return self + + # Test name generation diff --git a/test/test_ddt2_internals.py b/test/test_ddt2_internals.py index d2ce2be..b935394 100644 --- a/test/test_ddt2_internals.py +++ b/test/test_ddt2_internals.py @@ -53,3 +53,145 @@ class SampleInt(int): v.__name__ = 'custom name' self.assertEqual(ddt.make_params_name(3, 'myint', v), '3_myint') + + +class TestParams(TestCase): + + def test__Params_has_name_attribute(self): + p = ddt.Params('sample_parameters', [], {}) + self.assertEqual(p.name, 'sample_parameters') + + def test__Params_has_args_attribute(self): + p = ddt.Params('p', ['a', 'b', 'c'], {}) + self.assertEqual(p.args, ['a', 'b', 'c']) + + def test__Params_has_kwargs_attribute(self): + p = ddt.Params('p', [], dict(a=1, b=2, c=3)) + self.assertEqual(p.kwargs, dict(a=1, b=2, c=3)) + + def test__ParamsFailure_has_name_attribute(self): + reason = Exception() + p = ddt.ParamsFailure('error', reason) + self.assertEqual(p.name, 'error') + + def test__ParamsFailure_has_reason_attribute(self): + reason = Exception() + p = ddt.ParamsFailure('error', reason) + self.assertEqual(p.reason, reason) + + def test__Params_unpack_unpacks_lists_in_args(self): + p = ddt.Params('p', [['a', 'b'], ['c']], {}) + p.unpack() + self.assertEqual(p.args, ['a', 'b', 'c']) + + def test__Params_unpack_unpacks_tuples_in_args(self): + p = ddt.Params('p', [('a', 'b'), ('c',)], {}) + p.unpack() + self.assertEqual(p.args, ['a', 'b', 'c']) + + def test__Params_unpack_unpacks_dicts_in_args(self): + p = ddt.Params('p', [dict(a=1, b=1), dict(a=2, c=2)], dict(b=0, d=0)) + p.unpack() + self.assertEqual(p.kwargs, dict(a=2, b=1, c=2, d=0)) + + def test__Params_unpack_treats_scalars_in_args_as_singletons(self): + p = ddt.Params( + 'p', + [['a', 'b'], 'c', dict(b=1, c=1), dict(c=2, d=2)], + dict(a=0, b=0) + ) + p.unpack() + self.assertEqual(p.args, ['a', 'b', 'c']) + self.assertEqual(p.kwargs, dict(a=0, b=1, c=2, d=2)) + + def test__Params_unpack_does_nothing_with_argument_0(self): + p = ddt.Params( + 'p', + [['a', 'b'], 'c', dict(b=1, c=1), dict(c=2, d=2)], + dict(a=0, b=0) + ) + p.unpack(0) + + self.assertEqual( + p.args, + [['a', 'b'], 'c', dict(b=1, c=1), dict(c=2, d=2)] + ) + self.assertEqual(p.kwargs, dict(a=0, b=0)) + + def test__Params_unpack_unpacks_two_levels_with_argument_2(self): + p = ddt.Params( + 'p', + [[['a', 'b']], [dict(b=1, c=1)], [[dict(c=2, d=2)]]], + dict(a=0, b=0) + ) + p.unpack(2) + + self.assertEqual(p.args, ['a', 'b', dict(c=2, d=2)]) + self.assertEqual(p.kwargs, dict(a=0, b=1, c=1)) + + def test_Params_unpack_returns_self(self): + p = ddt.Params('p', ['a', 'b'], dict(a=0, b=0)) + p1 = p.unpack() + self.assertEqual(p, p1) + + def test__ParamsFailure_unpack_does_nothing(self): + reason = Exception() + p = ddt.ParamsFailure('error', reason) + p.unpack() + self.assertEqual(p.name, 'error') + self.assertEqual(p.reason, reason) + + def test_ParamsFailure_unpack_returns_self(self): + reason = Exception() + p = ddt.ParamsFailure('error', reason) + p1 = p.unpack() + self.assertEqual(p, p1) + + def test__ParamsA_plus_ParamsB_is_ParamsAB(self): + p1 = ddt.Params('p1', ['a', 'b'], dict(x=0, y=0)) + p2 = ddt.Params('p2', ['c', 'd'], dict(y=1, z=1)) + p = p1 + p2 + self.assertIsInstance(p, ddt.Params) + self.assertEqual(p.name, 'p1__p2') + self.assertEqual(p.args, ['a', 'b', 'c', 'd']) + self.assertEqual(p.kwargs, dict(x=0, y=1, z=1)) + + def test__Params_plus_ParamsFailure_is_ParamsFailure(self): + reason = Exception() + p1 = ddt.Params('p1', ['a', 'b'], dict(x=0, y=0)) + p2 = ddt.ParamsFailure('error', reason) + p = p1 + p2 + self.assertIsInstance(p, ddt.ParamsFailure) + self.assertEqual(p.name, 'p1__error') + self.assertEqual(p.reason, reason) + + def test__ParamsFailure_plus_Params_is_ParamsFailure(self): + reason = Exception() + p1 = ddt.ParamsFailure('error', reason) + p2 = ddt.Params('p2', ['a', 'b'], dict(x=0, y=0)) + p = p1 + p2 + self.assertIsInstance(p, ddt.ParamsFailure) + self.assertEqual(p.name, 'error__p2') + self.assertEqual(p.reason, reason) + + def test__ParamsFailureA_plus_ParamsFailureB_is_ParamsFailureA(self): + reason1 = Exception("Reason 1") + reason2 = Exception("Reason 2") + p1 = ddt.ParamsFailure('error1', reason1) + p2 = ddt.ParamsFailure('error2', reason2) + p = p1 + p2 + self.assertIsInstance(p, ddt.ParamsFailure) + self.assertEqual(p.name, 'error1__error2') + self.assertEqual(p.reason, reason1) + + def test__combine_names__None_None(self): + self.assertEqual(ddt.combine_names(None, None), None) + + def test__combine_names__None_value(self): + self.assertEqual(ddt.combine_names(None, 'name2'), 'name2') + + def test__combine_names__value_None(self): + self.assertEqual(ddt.combine_names('name1', None), 'name1') + + def test__combine_names__value_value(self): + self.assertEqual(ddt.combine_names('name1', 'name2'), 'name1__name2') From aebcdecf061f10ffdcb265e95bcc4a81e0f4427c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 12:43:17 +0200 Subject: [PATCH 03/17] Data structures for deferred processing of data and file_data decorators The data and file_data decorators will just create and store instances of InlineParamsSet and FileParamsSet, respectively. Both classes implement the same interface so the ddt decorator will no longer need to distinguish between them when generating tests for individual combinations of test parameters. --- ddt.py | 119 ++++++++++++++++++++++++++++++++++++ test/test_data_invalid.json | 3 + test/test_data_utf8sig.json | 3 + test/test_ddt2_internals.py | 80 ++++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 test/test_data_invalid.json create mode 100644 test/test_data_utf8sig.json diff --git a/ddt.py b/ddt.py index 42c6619..95e8336 100644 --- a/ddt.py +++ b/ddt.py @@ -6,6 +6,7 @@ # https://github.com/txels/ddt/blob/master/LICENSE.md import inspect +import io import json import os import re @@ -337,6 +338,124 @@ def __add__(self, other): return self +class BaseParamsSet(object): + """ + It is convenient that InlineParamsSet and FileParamsSet implement the same + interface. This base class provides convenient implementations of common + methods. + + """ + + def __init__(self): + self.pending_unpack = 0 + + def unpack(self): + """ + Keep count of the number of times arguments should be unpacked. + + This method is called directly by the @unpack decorator. + + """ + self.pending_unpack = self.pending_unpack + 1 + + def use_class(self, cls): + """ + FileParamsSet needs to know the class of the decorated test so that it + knows the base path for loading the data file. However, the class is + not known when the object is created. This method provides a way to + supply it later on. + + """ + return self + + +class InlineParamsSet(BaseParamsSet): + """ + This class represents test parameters supplied in a single @data decorator. + + The object is an iterator that returns individual parameters as instances + of Params. + + """ + + def __init__(self, *unnamed_values, **named_values): + super(InlineParamsSet, self).__init__() + self.unnamed_values = unnamed_values + self.named_values = named_values + + def __iter__(self): + for idx, value in enumerate(self.unnamed_values): + name = make_params_name(idx, None, value) + yield Params(name, [value], {}).unpack(self.pending_unpack) + + for idx, key in enumerate( + sorted(self.named_values), + start=len(self.unnamed_values) + ): + value = self.named_values[key] + name = make_params_name(idx, key, value) + yield Params(name, [value], {}).unpack(self.pending_unpack) + + +class FileParamsSet(BaseParamsSet): + """This class represents test parameters from a JSON file specified in a + @file_data decorator. + + The object is an iterator that returns individual parameters as instances + of Params or, if the file could not be loaded, a single instance of + ParamsFailure. + + Note that the file is not loaded until the iterator interface is invoked. + """ + + def __init__(self, filepath, encoding=None): + super(FileParamsSet, self).__init__() + self.pathbase = '' + self.filepath = filepath + self.encoding = encoding + + def use_class(self, cls): + cls_path = os.path.abspath(inspect.getsourcefile(cls)) + self.pathbase = os.path.dirname(cls_path) + return self + + def load_data(self): + try: + filepath = os.path.join(self.pathbase, self.filepath) + with io.open(filepath, encoding=self.encoding) as file: + data = json.load(file) + + return data + + except OSError as reason: + # Python 3 + return ParamsFailure(reason.__class__.__name__, reason) + + except IOError as reason: + # Python 2 + return ParamsFailure(reason.__class__.__name__, reason) + + except ValueError as reason: + return ParamsFailure(reason.__class__.__name__, reason) + + def __iter__(self): + data = self.load_data() + + if isinstance(data, ParamsFailure): + yield data + + elif isinstance(data, list): + for idx, value in enumerate(data): + name = make_params_name(idx, None, value) + yield Params(name, [value], {}).unpack(self.pending_unpack) + + elif isinstance(data, dict): + for idx, key in enumerate(sorted(data)): + value = data[key] + name = make_params_name(idx, key, value) + yield Params(name, [value], {}).unpack(self.pending_unpack) + + # Test name generation diff --git a/test/test_data_invalid.json b/test/test_data_invalid.json new file mode 100644 index 0000000..e534a76 --- /dev/null +++ b/test/test_data_invalid.json @@ -0,0 +1,3 @@ +[ + "Hello + diff --git a/test/test_data_utf8sig.json b/test/test_data_utf8sig.json new file mode 100644 index 0000000..cbf6de6 --- /dev/null +++ b/test/test_data_utf8sig.json @@ -0,0 +1,3 @@ +[ + "Ř" +] diff --git a/test/test_ddt2_internals.py b/test/test_ddt2_internals.py index b935394..ef591cd 100644 --- a/test/test_ddt2_internals.py +++ b/test/test_ddt2_internals.py @@ -1,6 +1,8 @@ from unittest import TestCase +import six + import ddt @@ -195,3 +197,81 @@ def test__combine_names__value_None(self): def test__combine_names__value_value(self): self.assertEqual(ddt.combine_names('name1', 'name2'), 'name1__name2') + + +class TestParamsSet(TestCase): + + def test__InlineParamsSet_generates_unnamed_and_named_Params(self): + ps = ddt.InlineParamsSet('b', 'a', z='c', y='d') + + params = list(ps) + names = ['0_b', '1_a', '2_y', '3_z'] + values = [['b'], ['a'], ['d'], ['c']] + + self.assertEqual(len(values), 4) + for p, n, v in zip(params, names, values): + self.assertIsInstance(p, ddt.Params) + self.assertEqual(p.name, n) + self.assertEqual(p.args, v) + self.assertEqual(p.kwargs, {}) + + def test__FileParamsSet_generates_unnamed_Params_from_list(self): + ps = ddt.FileParamsSet('test_data_list.json') + ps.use_class(self.__class__) + + params = list(ps) + names = ['0_Hello', '1_Goodbye'] + values = [['Hello'], ['Goodbye']] + + self.assertEqual(len(values), 2) + for p, n, v in zip(params, names, values): + self.assertIsInstance(p, ddt.Params) + self.assertEqual(p.name, n) + self.assertEqual(p.args, v) + self.assertEqual(p.kwargs, {}) + + def test__FileParamsSet_generates_named_Params_from_dict(self): + ps = ddt.FileParamsSet('test_data_dict.json') + ps.use_class(self.__class__) + + params = list(ps) + names = ['0_sorted_list', '1_unsorted_list'] + values = [[[15, 12, 50]], [[10, 12, 15]]] + + self.assertEqual(len(values), 2) + for p, n, v in zip(params, names, values): + self.assertIsInstance(p, ddt.Params) + self.assertEqual(p.name, n) + self.assertEqual(p.args, v) + self.assertEqual(p.kwargs, {}) + + def test__FileParamsSet_generates_ParamsFailure_if_file_not_found(self): + ps = ddt.FileParamsSet('test_no_such_file.json') + ps.use_class(self.__class__) + + params = list(ps) + + self.assertEqual(len(params), 1) + self.assertIsInstance(params[0], ddt.ParamsFailure) + if six.PY2: + self.assertEqual(params[0].name, 'IOError') + self.assertIsInstance(params[0].reason, IOError) + if six.PY3: + self.assertEqual(params[0].name, 'FileNotFoundError') + self.assertIsInstance(params[0].reason, FileNotFoundError) + self.assertIn("No such file or directory", str(params[0].reason)) + + def test__FileParamsSet_generates_ParamsFailure_on_invalid_JSON(self): + ps = ddt.FileParamsSet('test_data_invalid.json') + ps.use_class(self.__class__) + + params = list(ps) + + self.assertEqual(len(params), 1) + self.assertIsInstance(params[0], ddt.ParamsFailure) + self.assertEqual(params[0].name, 'ValueError') + self.assertIsInstance(params[0].reason, ValueError) + self.assertIn( + "Invalid control character at: line 2 column 11", + str(params[0].reason) + ) From ae0a39377d000ade5f5ef5e4bc63d490e909fe3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 12:52:29 +0200 Subject: [PATCH 04/17] Ignore pep8 F821 fired by guarded Python3 tests executed in Python2 First, I do not like the idea of masking Python3 capabilities (exceptions in this case) just because we support Python2, too. Second, I want to test it properly, meaning separate tests for Python2 and Python3 where the details differ. Perhaps there is a cleaner way to test version-specific behavior and pass all pep8 tests, but I do not know it. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b8c1edf..012319a 100644 --- a/tox.ini +++ b/tox.ini @@ -19,5 +19,5 @@ deps = sphinxcontrib-programoutput commands = nosetests -s --with-coverage --cover-package=ddt --cover-html - flake8 ddt.py test + flake8 --ignore=F821 ddt.py test sphinx-build -b html docs docs/_build From 8d46caf2cce0150acc1425b695aa829f2a316b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 13:01:37 +0200 Subject: [PATCH 05/17] Big bang: Decorators implemented using the new data structures The change breaks some older tests. I am not fixing them in this commit to make backward-incompatible changes more obvious. --- ddt.py | 240 ++++++--------- test/test_ddt2_specification.py | 511 ++++++++++++++++++++++++++++++++ 2 files changed, 604 insertions(+), 147 deletions(-) create mode 100644 test/test_ddt2_specification.py diff --git a/ddt.py b/ddt.py index 95e8336..6881724 100644 --- a/ddt.py +++ b/ddt.py @@ -7,6 +7,7 @@ import inspect import io +import itertools import json import os import re @@ -19,181 +20,108 @@ # 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 +PARAMS_SETS_ATTR = '%params_sets' # store a list of *ParamsSet objects +UNPACKALL_ATTR = '%unpackall' # remember @unpackall decorators + + +# Public interface - Decorators def unpack(func): """ - Method decorator to add unpack feature. + Method decorator to unpack parameters from the next (syntactically) + parameters set (``@data`` or ``@file_data`` decorator) by one level. + + Multiple levels are unpacked if ``@unpack`` and ``@unpackall`` are combined + and/or applied multiple times. """ - setattr(func, UNPACK_ATTR, True) + getattr(func, PARAMS_SETS_ATTR)[0].unpack() return func -def data(*values): +def unpackall(func): """ - Method decorator to add to your test methods. + Method decorator to unpack parameters in all parameter sets by one level. - Should be added to methods of instances of ``unittest.TestCase``. + Multiple levels are unpacked if ``@unpack`` and ``@unpackall`` are combined + and/or applied multiple times. """ - def wrapper(func): - setattr(func, DATA_ATTR, values) - return func - return wrapper + setattr(func, UNPACKALL_ATTR, getattr(func, UNPACKALL_ATTR, 0) + 1) + return func -def file_data(value): +def data(*unnamed_values, **named_values): """ - Method decorator to add to your test methods. + Method decorator to supply parameters to your test methods in-line. 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. + Keyword arguments can be used to explicitly define names of values to be + used in names of created test methods. """ def wrapper(func): - setattr(func, FILE_ATTR, value) + if not hasattr(func, PARAMS_SETS_ATTR): + setattr(func, PARAMS_SETS_ATTR, []) + params = InlineParamsSet(*unnamed_values, **named_values) + getattr(func, PARAMS_SETS_ATTR).insert(0, params) return func return wrapper -def mk_test_name(name, value, index=0): +def file_data(filepath, encoding=None): """ - 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 + Method decorator to supply parameters to your test method from a JSON file. - 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) + Should be added to methods of instances of ``unittest.TestCase``. + ``filepath`` should be a path relative to the directory of the file + containing the decorated ``unittest.TestCase``. -def feed_data(func, new_name, *args, **kwargs): - """ - This internal method decorator feeds the test data item to the test. + The file should contain JSON-encoded data, that can either be a list or a + dict. A list supplies unnamed parameters. A dict supplies named parameters + where keys are used to identify individual parameters. """ - @wraps(func) - def wrapper(self): - return func(self, *args, **kwargs) - wrapper.__name__ = new_name + def wrapper(func): + if not hasattr(func, PARAMS_SETS_ATTR): + setattr(func, PARAMS_SETS_ATTR, []) + params = FileParamsSet(filepath, encoding=encoding) + getattr(func, PARAMS_SETS_ATTR).insert(0, params) + return func return wrapper -def add_test(cls, 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(cls, 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): # pylint: disable-msg=W0613 - 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(cls, 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(cls, test_name, func, value) - - 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``. + Apply this decorator to the test case class, and then decorate test methods + with ``@data``, ``@file_data``, ``@unpack``, and ``@unpackall``. - For each method decorated with ``@data``, this will effectively create as - many methods as data items are passed as parameters to ``@data``. + For each method decorated with ``@data`` and ``@file_data``, this will + effectively create one method for each member of the Cartesian product of + data items passed to ``@data`` or read from a JSON file by ``@file_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. + ``original_test_name(__{ordinal}_{data})+``. - 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. + The part ``__{ordinal}_{data}`` is repeated as many times as there are + nested ``@data`` and ``@file_data`` decorators. + + ``ordinal`` is the position of a particular data item in the list of + options represented by the corresponding decorator. Positions are numbered + from 0. Keyword arguments (for ``@data``) and members of top-level dict + (for ``@data_file``) are ordered according to their keys. + + ``data`` attempts to provide human readable identification of the data item + with the following priority: 1) The keyword is used for data items passed + as keyword arguments (for ``@data``) or members of the top-level dict (for + ``@file_data``). 2) The ``__name__`` attribute of the value if it exists. + 3) An explicit namestring representation of the data value converted into a + valid python identifier, if possible. 4) Only the position is used. For each method decorated with ``@file_data('test_data.json')``, the decorator will try to load the test_data.json file located relative @@ -203,25 +131,43 @@ def ddt(cls): """ 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(cls, test_name, func, *v) - else: - # unpack dictionary - add_test(cls, test_name, func, **v) - else: - add_test(cls, test_name, func, v) - delattr(cls, name) - elif hasattr(func, FILE_ATTR): - file_attr = getattr(func, FILE_ATTR) - process_file_data(cls, name, func, file_attr) + if hasattr(func, PARAMS_SETS_ATTR): + test_vectors = itertools.product(*( + map( + lambda s: s.use_class(cls), + getattr(func, PARAMS_SETS_ATTR) + ) + )) + for vector in test_vectors: + params = sum(vector, Params(name, [], {})) + params.unpack(getattr(func, UNPACKALL_ATTR, 0)) + add_test(cls, func, params) delattr(cls, name) + return cls +# Adding new tests + + +def add_test(cls, func, params): + """Add a test derived from an original function and specific combination of + values provided by ``@data`` and ``@file_data`` decorators. + + """ + if isinstance(params, Params): + @wraps(func) + def test_case_func(self): + return func(self, *params.args, **params.kwargs) + + else: + def test_case_func(self): + raise params.reason + + test_case_func.__name__ = params.name + setattr(cls, params.name, test_case_func) + + # Internal data structures diff --git a/test/test_ddt2_specification.py b/test/test_ddt2_specification.py new file mode 100644 index 0000000..ae2f933 --- /dev/null +++ b/test/test_ddt2_specification.py @@ -0,0 +1,511 @@ + +from unittest import TestCase + +import gc +import warnings +import six + +import ddt + + +class TestDecorators(TestCase): + + def test__data_decor_adds_derived_tests_for_unnamed_values(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data('a', 'b') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a() + self.assertEqual(args, ('a',)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_b() + self.assertEqual(args, ('b',)) + self.assertEqual(kwargs, {}) + + def test__data_decor_adds_derived_tests_for_named_values(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(x='a', y='b') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_x() + self.assertEqual(args, ('a',)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_y() + self.assertEqual(args, ('b',)) + self.assertEqual(kwargs, {}) + + def test__data_decor_adds_derived_tests_for_combined_values(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data('A', x='B') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A() + self.assertEqual(args, ('A',)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_x() + self.assertEqual(args, ('B',)) + self.assertEqual(kwargs, {}) + + def test__unpack_unpacks_list_in_single_value_set(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.data(a=['A', 'B']) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a() + self.assertEqual(args, ('A', 'B')) + self.assertEqual(kwargs, {}) + + def test__unpack_unpacks_tuple_in_single_value_set(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.data(a=('A', 'B')) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a() + self.assertEqual(args, ('A', 'B')) + self.assertEqual(kwargs, {}) + + def test__unpack_preserves_scalar_in_single_value_set(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.data(a='A') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a() + self.assertEqual(args, ('A',)) + self.assertEqual(kwargs, {}) + + def test__unpack_converts_dict_to_kwargs_in_single_value_set(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.data(a=dict(y='A', x='B')) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a() + self.assertEqual(args, ()) + self.assertEqual(kwargs, dict(x='B', y='A')) + + def test__nested_data_decorators_produce_cartesian_product(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data('a', 'b') + @ddt.data('c', 'd') + @ddt.data('e') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a__0_c__0_e() + self.assertEqual(args, ('a', 'c', 'e')) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__0_a__1_d__0_e() + self.assertEqual(args, ('a', 'd', 'e')) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_b__0_c__0_e() + self.assertEqual(args, ('b', 'c', 'e')) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_b__1_d__0_e() + self.assertEqual(args, ('b', 'd', 'e')) + self.assertEqual(kwargs, {}) + + def test__unpack_unpacks_only_the_next_data_decorator(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(A=['a', 'b']) + @ddt.unpack + @ddt.data(B=['c', 'd']) + @ddt.data(C=dict(x='e', y='f')) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C() + self.assertEqual(args, (['a', 'b'], 'c', 'd', dict(x='e', y='f'))) + self.assertEqual(kwargs, {}) + + def test__multiple_unpack_decorators_unpack_nested_lists(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(A=['a', 'b']) + @ddt.unpack + @ddt.unpack + @ddt.data(B=[('c', 'd')]) + @ddt.data(C=dict(x='e', y='f')) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C() + self.assertEqual(args, (['a', 'b'], 'c', 'd', dict(x='e', y='f'))) + self.assertEqual(kwargs, {}) + + def test__multiple_unpack_decorators_are_idempotent_on_dicts(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(A=['a', 'b']) + @ddt.unpack + @ddt.unpack + @ddt.data(B=dict(r='c', s='d')) + @ddt.data(C=dict(x='e', y='f')) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C() + self.assertEqual(args, (['a', 'b'], dict(x='e', y='f'))) + self.assertEqual(kwargs, dict(r='c', s='d')) + + def test__multiple_unpacked_dicts_are_combined(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(A=dict(a=0, b=0)) + @ddt.unpack + @ddt.data(B=dict(b=1, c=1)) + @ddt.unpack + @ddt.unpack + @ddt.data(C=[dict(c=2, d=2)]) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C() + self.assertEqual(args, (dict(a=0, b=0),)) + self.assertEqual(kwargs, dict(b=1, c=2, d=2)) + + def test__unpackall_unpacks_data_from_all_decorators(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpackall + @ddt.data(A=['a', 'b']) + @ddt.data(B=dict(a=0, b=0)) + @ddt.data(C=[dict(b=1, c=1)]) + @ddt.data(D=[['c', 'd']]) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C__0_D() + self.assertEqual(args, ('a', 'b', dict(b=1, c=1), ['c', 'd'])) + self.assertEqual(kwargs, dict(a=0, b=0)) + + def test__position_of_unpackall_does_not_matter(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(A=['a', 'b']) + @ddt.data(B=dict(a=0, b=0)) + @ddt.data(C=[dict(b=1, c=1)]) + @ddt.data(D=[['c', 'd']]) + @ddt.unpackall + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C__0_D() + self.assertEqual(args, ('a', 'b', dict(b=1, c=1), ['c', 'd'])) + self.assertEqual(kwargs, dict(a=0, b=0)) + + def test__multiple_unpackall_decorators_unpack_nested_lists(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpackall + @ddt.data(A=['a', 'b']) + @ddt.data(B=dict(a=0, b=0)) + @ddt.data(C=[dict(b=1, c=1)]) + @ddt.data(D=[['c', 'd']]) + @ddt.unpackall + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C__0_D() + self.assertEqual(args, ('a', 'b', 'c', 'd')) + self.assertEqual(kwargs, dict(a=0, b=1, c=1)) + + def test__unpack_and_unpackall_decorators_combine_as_expected(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpackall + @ddt.data(A=[['a', 'b']]) + @ddt.data(B=[dict(a=0, b=0)]) + @ddt.unpack + @ddt.data(C=[dict(b=1, c=1)]) + @ddt.unpack + @ddt.data(D=[['c', 'd']]) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_A__0_B__0_C__0_D() + self.assertEqual(args, (['a', 'b'], dict(a=0, b=0), 'c', 'd')) + self.assertEqual(kwargs, dict(b=1, c=1)) + + def test__names_for_complex_values_are_generated_correctly(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(dict(a=0, b=0)) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + if ddt.is_hash_randomized(): + args, kwargs = tc.test__0() + else: + try: + args, kwargs = tc.test__0_a_0_b_0() + except AttributeError: + args, kwargs = tc.test__0_b_0_a_0() + + self.assertEqual(args, (dict(a=0, b=0),)) + self.assertEqual(kwargs, {}) + + def test__test_name_uses__name__attribute_if_complex_value_has_it(self): + + class SampleInt(int): + pass + + d1 = SampleInt(1) + d1.__name__ = 'custom name' + + d2 = SampleInt(2) + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(d1, d2) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_custom_name() + self.assertEqual(args, (1,)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_2() + self.assertEqual(args, (2,)) + self.assertEqual(kwargs, {}) + + def test__sequences_of_underscores_in_test_names_are_reduced_to_one(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.data(['a', 'b']) + @ddt.data((1, 2)) + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_a_b__0_1_2() + self.assertEqual(args, (['a', 'b'], (1, 2))) + self.assertEqual(kwargs, {}) + + def test__file_data_decor_adds_derived_tests_with_unnamed_values(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.file_data('test_data_list.json') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_Hello() + self.assertEqual(args, ('Hello',)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_Goodbye() + self.assertEqual(args, ('Goodbye',)) + self.assertEqual(kwargs, {}) + + def test__file_data_decor_adds_derived_tests_for_named_values(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.file_data('test_data_dict.json') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_sorted_list() + self.assertEqual(args, ([15, 12, 50],)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_unsorted_list() + self.assertEqual(args, ([10, 12, 15],)) + self.assertEqual(kwargs, {}) + + def test__unpack_works_for_file_data_decor_too(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.file_data('test_data_dict.json') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + args, kwargs = tc.test__0_sorted_list() + self.assertEqual(args, (15, 12, 50)) + self.assertEqual(kwargs, {}) + + args, kwargs = tc.test__1_unsorted_list() + self.assertEqual(args, (10, 12, 15)) + self.assertEqual(kwargs, {}) + + def test__file_data_decor_does_not_cause_resource_warning(self): + # ResourceWarning does not exist in Python 2 (?) + if six.PY3: + with warnings.catch_warnings(record=True) as w: + + warnings.resetwarnings() # clear all filters + warnings.simplefilter('ignore') # ignore all + warnings.simplefilter('always', ResourceWarning) # add filter + + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.file_data('test_data_dict.json') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + del tc + + gc.collect() + + self.assertEqual(list(w), []) + + def test__tests_with_file_data_raise_exceptions_on_file_not_found(self): + @ddt.ddt + class SampleTestCase(object): + @ddt.file_data('test_no_such_file.json') + @ddt.data('a', 'b') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + if six.PY2: + with self.assertRaises(IOError): + tc.test__IOError__0_a() + + with self.assertRaises(IOError): + tc.test__IOError__1_b() + + if six.PY3: + with self.assertRaises(FileNotFoundError): + tc.test__FileNotFoundError__0_a() + + with self.assertRaises(FileNotFoundError): + tc.test__FileNotFoundError__1_b() + + def test__tests_with_file_data_raise_exceptions_on_invalid_json(self): + @ddt.ddt + class SampleTestCase(object): + @ddt.file_data('test_data_invalid.json') + @ddt.data('a', 'b') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + with self.assertRaises(ValueError): + tc.test__ValueError__0_a() + + with self.assertRaises(ValueError): + tc.test__ValueError__1_b() + + def test__unpacking_is_safe_even_if_loading_file_data_fails(self): + @ddt.ddt + class SampleTestCase(object): + @ddt.unpack + @ddt.file_data('test_data_invalid.json') + @ddt.data('a', 'b') + @ddt.unpackall + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + with self.assertRaises(ValueError): + tc.test__ValueError__0_a() + + with self.assertRaises(ValueError): + tc.test__ValueError__1_b() + + def test__file_data_supports_file_encoding(self): + + @ddt.ddt + class SampleTestCase(object): + @ddt.file_data('test_data_utf8sig.json', encoding='utf-8-sig') + def test(self, *args, **kwargs): + return args, kwargs + + tc = SampleTestCase() + + method_name = 'test__0_u0158' if six.PY2 else 'test__0_\u0158' + self.assertTrue(hasattr(tc, method_name)) From 7827478e482ba8a5a03f4a18d02888b09529fe6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 13:16:23 +0200 Subject: [PATCH 06/17] Backward-incompatible changes - updating old tests 8 tests were broken due to the following changes: 1. The unpack decorator must precede a data or file_data decorator. This is the only change that is by design and cannot be undone. 2. Generated names are numbered from 0 instead of 1 - there is no obvious gain in starting from 1 but the "+1" makes code less clear. 3. Sequences of underscores in value representations are reduced to a single underscore. There is no meaning in five consecutive underscores. 4. The file_data decorator reports failures in data file processing in a more structured way. Details depend on Python version used. See also commit 204ecae16c2c932122ea3fe037ca761f8145441a. 5. Some tests were implementation-dependent. The implementation changed significantly. --- test/test_example.py | 4 ++-- test/test_functional.py | 41 ++++++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/test/test_example.py b/test/test_example.py index 35fc002..903df9e 100644 --- a/test/test_example.py +++ b/test/test_example.py @@ -39,13 +39,13 @@ def test_file_data_dict(self, value): def test_file_data_list(self, value): self.assertTrue(is_a_greeting(value)) - @data((3, 2), (4, 3), (5, 3)) @unpack + @data((3, 2), (4, 3), (5, 3)) def test_tuples_extracted_into_arguments(self, first_value, second_value): self.assertTrue(first_value > second_value) - @data([3, 2], [4, 3], [5, 3]) @unpack + @data([3, 2], [4, 3], [5, 3]) def test_list_extracted_into_arguments(self, first_value, second_value): self.assertTrue(first_value > second_value) diff --git a/test/test_functional.py b/test/test_functional.py index b0e8cfd..b248314 100644 --- a/test/test_functional.py +++ b/test/test_functional.py @@ -71,7 +71,7 @@ def hello(): extra_attrs = dh_keys - keys assert_equal(len(extra_attrs), 1) extra_attr = extra_attrs.pop() - assert_equal(getattr(data_hello, extra_attr), (1, 2)) + assert_equal(getattr(data_hello, extra_attr)[0].unnamed_values, (1, 2)) def test_file_data_decorator_with_dict(): @@ -84,7 +84,7 @@ def hello(): pre_size = len(hello.__dict__) keys = set(hello.__dict__.keys()) - data_hello = data("test_data_dict.json")(hello) + data_hello = file_data("test_data_dict.json")(hello) dh_keys = set(data_hello.__dict__.keys()) post_size = len(data_hello.__dict__) @@ -93,7 +93,10 @@ def hello(): extra_attrs = dh_keys - keys assert_equal(len(extra_attrs), 1) extra_attr = extra_attrs.pop() - assert_equal(getattr(data_hello, extra_attr), ("test_data_dict.json",)) + assert_equal( + getattr(data_hello, extra_attr)[0].filepath, + "test_data_dict.json" + ) is_test = lambda x: x.startswith('test_') @@ -130,8 +133,8 @@ def test_file_data_test_names_dict(): test_data_path = os.path.join(tests_dir, 'test_data_dict.json') test_data = json.loads(open(test_data_path).read()) created_tests = set([ - "test_something_again_{0}_{1}".format(index + 1, name) - for index, name in enumerate(test_data.keys()) + "test_something_again__{0}_{1}".format(index, name) + for index, name in enumerate(sorted(test_data.keys())) ]) assert_equal(tests, created_tests) @@ -176,7 +179,11 @@ def test_feed_data_file_data_missing_json(): obj = FileDataMissingDummy() for test in tests: method = getattr(obj, test) - assert_raises(ValueError, method) + if six.PY2: + assert_raises(IOError, method) + + if six.PY3: + assert_raises(FileNotFoundError, method) def test_ddt_data_name_attribute(): @@ -202,8 +209,8 @@ class Mytest(object): setattr(Mytest, 'test_hello', data_hello) ddt_mytest = ddt(Mytest) - assert_is_not_none(getattr(ddt_mytest, 'test_hello_1_data1')) - assert_is_not_none(getattr(ddt_mytest, 'test_hello_2_2')) + assert_is_not_none(getattr(ddt_mytest, 'test_hello__0_data1')) + assert_is_not_none(getattr(ddt_mytest, 'test_hello__1_2')) def test_ddt_data_unicode(): @@ -224,13 +231,13 @@ class Mytest(object): def test_hello(self, val): pass - assert_is_not_none(getattr(Mytest, 'test_hello_1_ascii')) - assert_is_not_none(getattr(Mytest, 'test_hello_2_non_ascii__u2603')) + assert_is_not_none(getattr(Mytest, 'test_hello__0_ascii')) + assert_is_not_none(getattr(Mytest, 'test_hello__1_non_ascii_u2603')) if is_hash_randomized(): - assert_is_not_none(getattr(Mytest, 'test_hello_3')) + assert_is_not_none(getattr(Mytest, 'test_hello__2')) else: assert_is_not_none(getattr(Mytest, - 'test_hello_3__u__u2603____data__')) + 'test_hello__2_u_u2603_data')) elif six.PY3: @@ -240,12 +247,12 @@ class Mytest(object): def test_hello(self, val): pass - assert_is_not_none(getattr(Mytest, 'test_hello_1_ascii')) - assert_is_not_none(getattr(Mytest, 'test_hello_2_non_ascii__')) + assert_is_not_none(getattr(Mytest, 'test_hello__0_ascii')) + assert_is_not_none(getattr(Mytest, 'test_hello__1_non_ascii')) if is_hash_randomized(): - assert_is_not_none(getattr(Mytest, 'test_hello_3')) + assert_is_not_none(getattr(Mytest, 'test_hello__2')) else: - assert_is_not_none(getattr(Mytest, 'test_hello_3________data__')) + assert_is_not_none(getattr(Mytest, 'test_hello__2_data')) def test_feed_data_with_invalid_identifier(): @@ -259,6 +266,6 @@ def test_feed_data_with_invalid_identifier(): method = getattr(obj, tests[0]) assert_equal( method.__name__, - 'test_data_with_invalid_identifier_1_32v2_g__Gmw845h_W_b53wi_' + 'test_data_with_invalid_identifier__0_32v2_g_Gmw845h_W_b53wi' ) assert_equal(method(), '32v2 g #Gmw845h$W b53wi.') From 9f6afbdf54b2367a6602e7a9aee5afc4c02db811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 13:21:50 +0200 Subject: [PATCH 07/17] New documentation With more functionality, I felt the need to structure the tutorial a bit more. --- docs/example.rst | 57 ----------- docs/index.rst | 9 +- docs/tutorial.rst | 213 +++++++++++++++++++++++++++++++++++++++++ test/json_dict.json | 7 ++ test/json_list.json | 6 ++ test/mycode.py | 6 +- test/test_example01.py | 17 ++++ test/test_example02.py | 17 ++++ test/test_example03.py | 17 ++++ test/test_example04.py | 18 ++++ test/test_example05.py | 18 ++++ test/test_example06.py | 19 ++++ test/test_example07.py | 20 ++++ test/test_example08.py | 11 +++ test/test_example09.py | 23 +++++ test/test_example10.py | 21 ++++ 16 files changed, 416 insertions(+), 63 deletions(-) delete mode 100644 docs/example.rst create mode 100644 docs/tutorial.rst create mode 100644 test/json_dict.json create mode 100644 test/json_list.json create mode 100644 test/test_example01.py create mode 100644 test/test_example02.py create mode 100644 test/test_example03.py create mode 100644 test/test_example04.py create mode 100644 test/test_example05.py create mode 100644 test/test_example06.py create mode 100644 test/test_example07.py create mode 100644 test/test_example08.py create mode 100644 test/test_example09.py create mode 100644 test/test_example10.py diff --git a/docs/example.rst b/docs/example.rst deleted file mode 100644 index c7addb4..0000000 --- a/docs/example.rst +++ /dev/null @@ -1,57 +0,0 @@ -Example usage -============= - -DDT consists of a class decorator ``ddt`` (for your ``TestCase`` subclass) -and two method decorators (for your tests that want to be multiplied): - -* ``data``: contains as many arguments as values you want to feed to the test. -* ``file_data``: will load test data from a JSON file. - -Normally each value within ``data`` will be passed as a single argument to -your test method. If these values are e.g. tuples, you will have to unpack them -inside your test. Alternatively, you can use an additional decorator, -``unpack``, that will automatically unpack tuples and lists into multiple -arguments, and dictionaries into multiple keyword arguments. See examples -below. - -This allows you to write your tests as: - -.. literalinclude:: ../test/test_example.py - :language: python - -Where ``test_data_dict.json``: - -.. literalinclude:: ../test/test_data_dict.json - :language: javascript - -and ``test_data_list.json``: - -.. literalinclude:: ../test/test_data_list.json - :language: javascript - -And then run them with your favourite test runner, e.g. if you use nose:: - - $ nosetests -v test/test_example.py - -.. - program-output:: nosetests -v ../test/test_example.py - -The number of test cases actually run and reported separately has been -multiplied. - - -DDT will try to give the new test cases meaningful names by converting the -data values to valid python identifiers. - - -.. note:: - - Python 2.7.3 introduced *hash randomization* which is by default - enabled on Python 3.3 and later. DDT's default mechanism to - generate meaningful test names will **not** use the test data value - as part of the name for complex types if hash randomization is - enabled. - - You can disable hash randomization by setting the - ``PYTHONHASHSEED`` environment variable to a fixed value before - running tests (``export PYTHONHASHSEED=1`` for example). diff --git a/docs/index.rst b/docs/index.rst index 4e340f8..603ec57 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,20 @@ Welcome to DDT's documentation! =============================== -DDT (Data-Driven Tests) allows you to multiply one test case -by running it with different test data, and make it appear as -multiple test cases. +DDT (Data-Driven Tests) allows you to multiply one test by running it with +different test data, and make it appear as multiple tests. You can find (and fork) the project in https://github.com/txels/ddt. DDT should work on Python2 and Python3, but we only officially test it for -versions 2.7 and 3.3. +versions 2.7, 3.3, and 3.4. Contents: .. toctree:: :maxdepth: 2 - example + tutorial api Indices and tables diff --git a/docs/tutorial.rst b/docs/tutorial.rst new file mode 100644 index 0000000..6d9e0f2 --- /dev/null +++ b/docs/tutorial.rst @@ -0,0 +1,213 @@ +Tutorial +======== + + +Imagine you have prepared a module that implements a function +``larger_than_two()``. The function takes a number and returns either `True` or +`False` depending on the condition 'larger than two'. You would like to test +the function on multiple values. + + +Tests without DDT +----------------- + +Without DDT, you would perhaps add a test for each value to your test case: + +.. literalinclude:: ../test/test_example01.py + :language: python + +Run with your favorite test runner, it will yield output like this:: + + $ nosetests -v test.test_example01.py + +.. program-output:: nosetests -w .. -v test/test_example01.py + +The test case is verbose and difficult to maintain, especially during early +stages of development. If the function call changes, four tests will have to be +updated manually. + + +Very Basic DDT Usage +-------------------- + +This is where DDT helps. You can use the ``ddt`` and ``data`` decorators to +generate the same tests as above using this code: + +.. literalinclude:: ../test/test_example02.py + :language: python + +Run with your favorite test runner, it will yield output like this:: + + $ nosetests -v test.test_example02.py + +.. program-output:: nosetests -w .. -v test/test_example02.py + +Four tests have been generated from the function ``test_decorated()`` -- one +test for each argument of the ``data`` decorator. Test names were autogenerated +by DDT from the supplied values. + +.. note:: + + Python 2.7.3 introduced *hash randomization* which is by default + enabled on Python 3.3 and later. DDT's default mechanism to + generate meaningful test names will **not** use the test data value + as part of the name for complex types if hash randomization is + enabled. Only the ordinal number would be used. + + You can disable hash randomization by setting the + ``PYTHONHASHSEED`` environment variable to a fixed value before + running tests (``export PYTHONHASHSEED=1`` for example). + + +Name Your Data +-------------- + +While DDT does its best at automatically generating meaningful test names from +supplied values, it cannot beat names wisely chosen by a human. Thus, it gives +you the possibility to name your data: just use keyword arguments instead of +positional ones with the ``data`` decorator. + +.. literalinclude:: ../test/test_example03.py + :language: python + +Run with your favorite test runner, it will yield the same output as if all +four tests were written manually:: + + $ nosetests -v test.test_example03.py + +.. program-output:: nosetests -w .. -v test/test_example03.py + + +Unpack ``list`` Parameters as Positional Arguments +-------------------------------------------------- + +So now we have short code and meaningful test names. However, the code is not +exactly clear as we are getting all test parameters as a single list. This is +where the ``unpack`` decorator comes in handy. + +.. literalinclude:: ../test/test_example04.py + :language: python + +The ``unpack`` decorator applies to the next ``data`` (or ``file_data``) +decorator (next in the source code; they are actually applied in the reverse +order). If an argument of the ``data`` decorator is a list or tuple, the +``unpack`` decorator unpacks the elements into positional arguments of the test +function. + + +Unpack ``dict`` Parameters as Keyword Arguments +----------------------------------------------- + +The ``unpack`` decorator also unpacks ``dict`` values into keyword arguments. +The previous example could be written as follows, with identical results: + +.. literalinclude:: ../test/test_example05.py + :language: python + + +Unpack Twice to Get Positional and Keyword Arguments +---------------------------------------------------- + +The true benefit of unpacking ``dict`` arguments emerges when your test subject +has an optional parameter. Let's say we extend our function ``larger_than_two`` +to accept strings. The function will take an optional argument to specify the +base to be used to convert the string into a number. Base 10 should be used by +default. + +A test for the extended functionality could look like this: + +.. literalinclude:: ../test/test_example06.py + :language: python + +There is no limit on how many times test parameters can be unpacked. Just keep +inserting the ``unpack`` deocrator in front of a ``data`` (or ``file_data``) +decorator. + + +Read Test Parameters from File +------------------------------ + +If a test is to be repeated for many variants of test parameters or if test +parameters contain long or complex values, it is not convenient to specify them +inline. You can read them from a JSON file instead using the ``file_data`` +decorator. + +If the top-level structure in the JSON data is a ``list``, individual elements +are processed as if they were given as positional arguments to the ``data`` +decorator. + +If the top-level structure in the JSON data is a ``dict``, individual elements +are processed as if they were given as keyword arguments to the ``data`` +decorator. + +Thus, with ``json_list.json`` containing + +.. literalinclude:: ../test/json_list.json + :language: javascript + +and ``json_dict.json`` containing + +.. literalinclude:: ../test/json_dict.json + :language: javascript + +the tests ``test_with_json_dict()`` and ``test_with_json_list()`` in the +following test case are equivalent to the test in the previous example. The +only difference is that ``test_with_json_list()`` will generate test names +automatically. + +.. literalinclude:: ../test/test_example07.py + :language: python + +The ``encoding`` argument is optional and defaults to ``None``; the system +default encoding is used in that case. + +Run with your favorite test runner, the test case will yield output like this:: + + $ nosetests -v test.test_example07.py + +.. program-output:: nosetests -w .. -v test/test_example07.py + + +Nest ``data`` and ``file_data`` to Generate Even More Tests +----------------------------------------------------------- + +Imagine that you want to test that a function is a homomorphism, i.e. ``f(a+b) += f(a) + f(b)``. Instead of listing all combinations of ``a`` and ``b`` +explicitly, you can nest ``data`` and ``file_data``. A new test will be defined +for each combination of values from the nested decorators. + +For instance, the following test case will run 20 tests: + +.. literalinclude:: ../test/test_example08.py + :language: python + +Another use case for nested ``data`` and ``file_data`` decorators is testing +default values of optional arguments. Unpacking of parameters can be specified +for each data set independently. + +For instance, the following test cases consists of 12 tests testing the +``str.split()`` method. + +.. literalinclude:: ../test/test_example09.py + :language: python + +.. note:: + + Apart from rare cases like the examples in this tutorial, tests whose + parameters come from two independent sets indicate that your code, be it the + test subject or the test itself, does two unrelated things which should be + decoupled. Use nesting with caution. + + +Save Lines of Code with ``unpackall`` +------------------------------------- + +It is likely that if you want to unpack parameters in one data set, you will +want to unpack parameters from all nested data sets. DDT has the ``unpackall`` +decorator to help you save some lines of code in this case. + +The following code specifies essentially the same tests of ``str.split()`` as the +previous example but in a more succinct way. + +.. literalinclude:: ../test/test_example10.py + :language: python diff --git a/test/json_dict.json b/test/json_dict.json new file mode 100644 index 0000000..cc478cf --- /dev/null +++ b/test/json_dict.json @@ -0,0 +1,7 @@ +{ + "base2_10": [ [ false, "10" ], { "base":2 } ], + "base3_10": [ [ true, "10" ], {"base":3 } ], + "base10_10": [ [ true, "10" ], {"base":10 } ], + "default_10": [ [ true, "10" ], {} ] +} + diff --git a/test/json_list.json b/test/json_list.json new file mode 100644 index 0000000..6d4dc37 --- /dev/null +++ b/test/json_list.json @@ -0,0 +1,6 @@ +[ + [ [ false, "10" ], { "base": 2} ], + [ [ true, "10" ], { "base": 3 } ], + [ [ true, "10" ], { "base": 10 } ], + [ [ true, "10" ], { } ] +] diff --git a/test/mycode.py b/test/mycode.py index bd562c8..a36d641 100644 --- a/test/mycode.py +++ b/test/mycode.py @@ -3,7 +3,11 @@ """ -def larger_than_two(value): +def larger_than_two(value, base=10): + try: + value = int(value, base=base) + except: + pass return value > 2 diff --git a/test/test_example01.py b/test/test_example01.py new file mode 100644 index 0000000..f2e9c9b --- /dev/null +++ b/test/test_example01.py @@ -0,0 +1,17 @@ +import unittest +from test.mycode import larger_than_two + + +class FooTestCase(unittest.TestCase): + + def test_manual_neg1(self): + self.assertFalse(larger_than_two(-1)) + + def test_manual_zero(self): + self.assertFalse(larger_than_two(0)) + + def test_manual_pos2(self): + self.assertFalse(larger_than_two(2)) + + def test_manual_pos3(self): + self.assertTrue(larger_than_two(3)) diff --git a/test/test_example02.py b/test/test_example02.py new file mode 100644 index 0000000..9201631 --- /dev/null +++ b/test/test_example02.py @@ -0,0 +1,17 @@ + +import unittest +from ddt import ddt, data +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @data( + [-1, False], + [0, False], + [2, False], + [3, True] + ) + def test_decorated(self, data): + self.assertEqual(larger_than_two(data[0]), data[1]) diff --git a/test/test_example03.py b/test/test_example03.py new file mode 100644 index 0000000..242a9f8 --- /dev/null +++ b/test/test_example03.py @@ -0,0 +1,17 @@ + +import unittest +from ddt import ddt, data +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @data( + neg1=[-1, False], + zero=[0, False], + pos2=[2, False], + pos3=[3, True], + ) + def test_decorated(self, data): + self.assertEqual(larger_than_two(data[0]), data[1]) diff --git a/test/test_example04.py b/test/test_example04.py new file mode 100644 index 0000000..b274c42 --- /dev/null +++ b/test/test_example04.py @@ -0,0 +1,18 @@ + +import unittest +from ddt import ddt, data, unpack +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpack + @data( + neg1=[-1, False], + zero=[0, False], + pos2=[2, False], + pos3=[3, True], + ) + def test_decorated(self, value, result): + self.assertEqual(larger_than_two(value), result) diff --git a/test/test_example05.py b/test/test_example05.py new file mode 100644 index 0000000..56e9ebd --- /dev/null +++ b/test/test_example05.py @@ -0,0 +1,18 @@ + +import unittest +from ddt import ddt, data, unpack +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpack + @data( + neg1={"value": -1, "result": False}, + zero={"value": 0, "result": False}, + pos2={"value": 2, "result": False}, + pos3={"value": 3, "result": True}, + ) + def test_decorated(self, value, result): + self.assertEqual(larger_than_two(value), result) diff --git a/test/test_example06.py b/test/test_example06.py new file mode 100644 index 0000000..feb2a95 --- /dev/null +++ b/test/test_example06.py @@ -0,0 +1,19 @@ + +import unittest +from ddt import ddt, data, unpack +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpack + @unpack + @data( + base2_10=[[False, "10"], {"base": 2}], + base3_10=[[True, "10"], {"base": 3}], + base10_10=[[True, "10"], {"base": 10}], + default_10=[[True, "10"], {}], + ) + def test_decorated(self, result, value, **kwargs): + self.assertEqual(larger_than_two(value, **kwargs), result) diff --git a/test/test_example07.py b/test/test_example07.py new file mode 100644 index 0000000..c30849f --- /dev/null +++ b/test/test_example07.py @@ -0,0 +1,20 @@ + +import unittest +from ddt import ddt, file_data, unpack +from test.mycode import larger_than_two + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpack + @unpack + @file_data("json_list.json", encoding="ascii") + def test_with_json_list(self, result, value, **kwargs): + self.assertEqual(larger_than_two(value, **kwargs), result) + + @unpack + @unpack + @file_data("json_dict.json") + def test_with_json_dict(self, result, value, **kwargs): + self.assertEqual(larger_than_two(value, **kwargs), result) diff --git a/test/test_example08.py b/test/test_example08.py new file mode 100644 index 0000000..ec4880f --- /dev/null +++ b/test/test_example08.py @@ -0,0 +1,11 @@ +import unittest +from ddt import ddt, data + + +@ddt +class FooTestCase(unittest.TestCase): + + @data("a", "_", "B", "1") + @data("b", "*", "X", "2", "") + def test_decorated(self, a, b): + self.assertEqual((a + b).lower(), a.lower() + b.lower()) diff --git a/test/test_example09.py b/test/test_example09.py new file mode 100644 index 0000000..a5744e6 --- /dev/null +++ b/test/test_example09.py @@ -0,0 +1,23 @@ +import unittest +from ddt import ddt, data, unpack + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpack + @data( + no_options=[[]], + with_sep_None=[[None]], + with_sep_None_maxsplit_neg1=[[None, -1]], + ) + @unpack + @unpack + @data( + empty_string=[[""], {"result": []}], + only_spaces=[[" "], {"result": []}], + two_words=[[" ab cd "], {"result": ["ab", "cd"]}], + three_words=[["ab cd ef"], {"result": ["ab", "cd", "ef"]}], + ) + def test_decorated(self, args, value, result=None): + self.assertEqual(value.split(*args), result) diff --git a/test/test_example10.py b/test/test_example10.py new file mode 100644 index 0000000..7d6ddab --- /dev/null +++ b/test/test_example10.py @@ -0,0 +1,21 @@ +import unittest +from ddt import ddt, data, unpackall + + +@ddt +class FooTestCase(unittest.TestCase): + + @unpackall + @data( + empty_string=[[], ""], + only_spaces=[[], " "], + two_words=[["ab", "cd"], " ab cd "], + three_words=[["ab", "cd", "ef"], "ab cd ef"], + ) + @data( + no_options=[], + with_sep_None=[None], + with_sep_None_maxsplit_neg1=[None, -1], + ) + def test_decorated(self, result, value, *args): + self.assertEqual(value.split(*args), result) From b36ad7d5562155c5684edca144ffad7d2c1c0e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 17:53:51 +0200 Subject: [PATCH 08/17] Refactoring internal data structures - new names, no base class, documentation InlineDataValues - Represents values passed to a @data decorator. FileDataValues - Represents values passed to a @file_data decorator. A common base class (formerly BaseParamsSet) has been dropped since the classes share a common interface rather than implementation. --- ddt.py | 105 +++++++++++++++++++++--------------- test/test_ddt2_internals.py | 20 +++---- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/ddt.py b/ddt.py index 6881724..f553cfd 100644 --- a/ddt.py +++ b/ddt.py @@ -65,7 +65,7 @@ def data(*unnamed_values, **named_values): def wrapper(func): if not hasattr(func, PARAMS_SETS_ATTR): setattr(func, PARAMS_SETS_ATTR, []) - params = InlineParamsSet(*unnamed_values, **named_values) + params = InlineDataValues(*unnamed_values, **named_values) getattr(func, PARAMS_SETS_ATTR).insert(0, params) return func return wrapper @@ -88,7 +88,7 @@ def file_data(filepath, encoding=None): def wrapper(func): if not hasattr(func, PARAMS_SETS_ATTR): setattr(func, PARAMS_SETS_ATTR, []) - params = FileParamsSet(filepath, encoding=encoding) + params = FileDataValues(filepath, encoding=encoding) getattr(func, PARAMS_SETS_ATTR).insert(0, params) return func return wrapper @@ -173,7 +173,7 @@ def test_case_func(self): class ParamsFailure: """ - FileParamsSet generates an instance of this class instead of instances of + FileDataValues generates an instance of this class instead of instances of Params in case it cannot load the data file. A fake test method is generated instead of a regular one if it has an instance of this class among its parameters. @@ -284,55 +284,48 @@ def __add__(self, other): return self -class BaseParamsSet(object): +class InlineDataValues(object): """ - It is convenient that InlineParamsSet and FileParamsSet implement the same - interface. This base class provides convenient implementations of common - methods. + This class represents values supplied to a ``data`` decorator. + + Instances of this class are generators which yield an instance of Params + for each positional and keyword argument passed to the constructor. """ - def __init__(self): - self.pending_unpack = 0 + def __init__(self, *unnamed_values, **named_values): + """ + Stores all arguments passed to the constructor for later use. + + """ + self.unpack_count = 0 + self.unnamed_values = unnamed_values + self.named_values = named_values def unpack(self): """ - Keep count of the number of times arguments should be unpacked. + Increases by one the number of times values should be unpacked before + passing them to the test function. - This method is called directly by the @unpack decorator. + This method is called directly by the ``unpack`` decorator. """ - self.pending_unpack = self.pending_unpack + 1 + self.unpack_count = self.unpack_count + 1 def use_class(self, cls): """ - FileParamsSet needs to know the class of the decorated test so that it - knows the base path for loading the data file. However, the class is - not known when the object is created. This method provides a way to - supply it later on. + Does nothing and returns self. + + This method just makes sure that `InlineDataValues` and + `FileDataValues` implement the same interface. """ return self - -class InlineParamsSet(BaseParamsSet): - """ - This class represents test parameters supplied in a single @data decorator. - - The object is an iterator that returns individual parameters as instances - of Params. - - """ - - def __init__(self, *unnamed_values, **named_values): - super(InlineParamsSet, self).__init__() - self.unnamed_values = unnamed_values - self.named_values = named_values - def __iter__(self): for idx, value in enumerate(self.unnamed_values): name = make_params_name(idx, None, value) - yield Params(name, [value], {}).unpack(self.pending_unpack) + yield Params(name, [value], {}).unpack(self.unpack_count) for idx, key in enumerate( sorted(self.named_values), @@ -340,31 +333,57 @@ def __iter__(self): ): value = self.named_values[key] name = make_params_name(idx, key, value) - yield Params(name, [value], {}).unpack(self.pending_unpack) + yield Params(name, [value], {}).unpack(self.unpack_count) + +class FileDataValues(object): + """This class represents values supplied to a ``file_data`` decorator. -class FileParamsSet(BaseParamsSet): - """This class represents test parameters from a JSON file specified in a - @file_data decorator. + Instances of this class are generators which load data from a given JSON + file and yield an instance of Params for each member of the top-level + structure (``list`` or ``dict``). - The object is an iterator that returns individual parameters as instances - of Params or, if the file could not be loaded, a single instance of - ParamsFailure. + The generator yields just one item if it fails to load data from the file. + The item is an instance of ParamsFailure that carries details about the + failure. - Note that the file is not loaded until the iterator interface is invoked. """ def __init__(self, filepath, encoding=None): - super(FileParamsSet, self).__init__() + """ + Stores all arguments passed to the constructor for later use. + + """ + self.unpack_count = 0 self.pathbase = '' self.filepath = filepath self.encoding = encoding def use_class(self, cls): + """ + Sets base path for reading the file to the directory that contains the + module where the class ``cls`` is defined. + + """ + + # Is there perhaps a way the constructor could retrieve this + # information somehow? This smells like a workaround that messes the + # interface. + cls_path = os.path.abspath(inspect.getsourcefile(cls)) self.pathbase = os.path.dirname(cls_path) return self + def unpack(self): + """ + Increase by one the number of times values should be unpacked before + passing them to the test function. + + This method is called directly by the @unpack decorator. + + """ + self.unpack_count = self.unpack_count + 1 + def load_data(self): try: filepath = os.path.join(self.pathbase, self.filepath) @@ -393,13 +412,13 @@ def __iter__(self): elif isinstance(data, list): for idx, value in enumerate(data): name = make_params_name(idx, None, value) - yield Params(name, [value], {}).unpack(self.pending_unpack) + yield Params(name, [value], {}).unpack(self.unpack_count) elif isinstance(data, dict): for idx, key in enumerate(sorted(data)): value = data[key] name = make_params_name(idx, key, value) - yield Params(name, [value], {}).unpack(self.pending_unpack) + yield Params(name, [value], {}).unpack(self.unpack_count) # Test name generation diff --git a/test/test_ddt2_internals.py b/test/test_ddt2_internals.py index ef591cd..d305d96 100644 --- a/test/test_ddt2_internals.py +++ b/test/test_ddt2_internals.py @@ -201,8 +201,8 @@ def test__combine_names__value_value(self): class TestParamsSet(TestCase): - def test__InlineParamsSet_generates_unnamed_and_named_Params(self): - ps = ddt.InlineParamsSet('b', 'a', z='c', y='d') + def test__InlineDataValues_generates_unnamed_and_named_Params(self): + ps = ddt.InlineDataValues('b', 'a', z='c', y='d') params = list(ps) names = ['0_b', '1_a', '2_y', '3_z'] @@ -215,8 +215,8 @@ def test__InlineParamsSet_generates_unnamed_and_named_Params(self): self.assertEqual(p.args, v) self.assertEqual(p.kwargs, {}) - def test__FileParamsSet_generates_unnamed_Params_from_list(self): - ps = ddt.FileParamsSet('test_data_list.json') + def test__FileDataValues_generates_unnamed_Params_from_list(self): + ps = ddt.FileDataValues('test_data_list.json') ps.use_class(self.__class__) params = list(ps) @@ -230,8 +230,8 @@ def test__FileParamsSet_generates_unnamed_Params_from_list(self): self.assertEqual(p.args, v) self.assertEqual(p.kwargs, {}) - def test__FileParamsSet_generates_named_Params_from_dict(self): - ps = ddt.FileParamsSet('test_data_dict.json') + def test__FileDataValues_generates_named_Params_from_dict(self): + ps = ddt.FileDataValues('test_data_dict.json') ps.use_class(self.__class__) params = list(ps) @@ -245,8 +245,8 @@ def test__FileParamsSet_generates_named_Params_from_dict(self): self.assertEqual(p.args, v) self.assertEqual(p.kwargs, {}) - def test__FileParamsSet_generates_ParamsFailure_if_file_not_found(self): - ps = ddt.FileParamsSet('test_no_such_file.json') + def test__FileDataValues_generates_ParamsFailure_if_file_not_found(self): + ps = ddt.FileDataValues('test_no_such_file.json') ps.use_class(self.__class__) params = list(ps) @@ -261,8 +261,8 @@ def test__FileParamsSet_generates_ParamsFailure_if_file_not_found(self): self.assertIsInstance(params[0].reason, FileNotFoundError) self.assertIn("No such file or directory", str(params[0].reason)) - def test__FileParamsSet_generates_ParamsFailure_on_invalid_JSON(self): - ps = ddt.FileParamsSet('test_data_invalid.json') + def test__FileDataValues_generates_ParamsFailure_on_invalid_JSON(self): + ps = ddt.FileDataValues('test_data_invalid.json') ps.use_class(self.__class__) params = list(ps) From 284438ada98f0714074e3db8822c5f4cf3b6f5b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:08:50 +0200 Subject: [PATCH 09/17] Package function trivial_types() turned into a package variable Thanks @cpennington --- ddt.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/ddt.py b/ddt.py index f553cfd..0cacc0a 100644 --- a/ddt.py +++ b/ddt.py @@ -24,6 +24,16 @@ UNPACKALL_ATTR = '%unpackall' # remember @unpackall decorators +# Types that can be converted into valid identifiers safely. +TRIVIAL_TYPES = (type(None), bool, str, int, float) + +# Extend the list a bit for Python2 +try: + TRIVIAL_TYPES += (unicode,) +except NameError: + pass + + # Public interface - Decorators @@ -438,28 +448,12 @@ def is_hash_randomized(): 'PYTHONHASHSEED' not in os.environ) -def trivial_types(): - """ - Return a tuple of types types that can be converted into valid - identifiers easily. - - """ - trivial_types = (type(None), bool, str, int, float) - - try: - trivial_types += (unicode,) - except NameError: - pass - - return trivial_types - - def is_trivial(value): """ - Check whether a value is of a trivial type w.r.t. `trivial_types()`. + Check whether a value is of a trivial type (w.r.t. `TRIVIAL_TYPES`). """ - if isinstance(value, trivial_types()): + if isinstance(value, TRIVIAL_TYPES): return True if isinstance(value, (list, tuple)): From d51fae7a52d4a500a0e7b08ba1b8e45ab6a0e60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:33:06 +0200 Subject: [PATCH 10/17] Use IOError as the common base for errors when loading data files IOError is an alias for OSError since Python 3.3 so there is no need for workarounds or guarded Python 3 code that fails on Python 2. --- ddt.py | 6 +----- test/test_ddt2_internals.py | 8 ++++---- test/test_ddt2_specification.py | 4 ++-- test/test_functional.py | 8 ++------ 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/ddt.py b/ddt.py index 0cacc0a..77291be 100644 --- a/ddt.py +++ b/ddt.py @@ -402,12 +402,8 @@ def load_data(self): return data - except OSError as reason: - # Python 3 - return ParamsFailure(reason.__class__.__name__, reason) - except IOError as reason: - # Python 2 + # IOError is an alias for OSError since Python 3.3 return ParamsFailure(reason.__class__.__name__, reason) except ValueError as reason: diff --git a/test/test_ddt2_internals.py b/test/test_ddt2_internals.py index d305d96..e2ded88 100644 --- a/test/test_ddt2_internals.py +++ b/test/test_ddt2_internals.py @@ -253,13 +253,13 @@ def test__FileDataValues_generates_ParamsFailure_if_file_not_found(self): self.assertEqual(len(params), 1) self.assertIsInstance(params[0], ddt.ParamsFailure) + self.assertIsInstance(params[0].reason, IOError) + self.assertIn("No such file or directory", str(params[0].reason)) + if six.PY2: self.assertEqual(params[0].name, 'IOError') - self.assertIsInstance(params[0].reason, IOError) - if six.PY3: + elif six.PY3: self.assertEqual(params[0].name, 'FileNotFoundError') - self.assertIsInstance(params[0].reason, FileNotFoundError) - self.assertIn("No such file or directory", str(params[0].reason)) def test__FileDataValues_generates_ParamsFailure_on_invalid_JSON(self): ps = ddt.FileDataValues('test_data_invalid.json') diff --git a/test/test_ddt2_specification.py b/test/test_ddt2_specification.py index ae2f933..8e059b8 100644 --- a/test/test_ddt2_specification.py +++ b/test/test_ddt2_specification.py @@ -457,10 +457,10 @@ def test(self, *args, **kwargs): tc.test__IOError__1_b() if six.PY3: - with self.assertRaises(FileNotFoundError): + with self.assertRaises(IOError): tc.test__FileNotFoundError__0_a() - with self.assertRaises(FileNotFoundError): + with self.assertRaises(IOError): tc.test__FileNotFoundError__1_b() def test__tests_with_file_data_raise_exceptions_on_invalid_json(self): diff --git a/test/test_functional.py b/test/test_functional.py index b248314..15780b2 100644 --- a/test/test_functional.py +++ b/test/test_functional.py @@ -172,18 +172,14 @@ def test_feed_data_file_data(): def test_feed_data_file_data_missing_json(): """ - Test that a ValueError is raised + Test that an IOError is raised """ tests = filter(is_test, FileDataMissingDummy.__dict__) obj = FileDataMissingDummy() for test in tests: method = getattr(obj, test) - if six.PY2: - assert_raises(IOError, method) - - if six.PY3: - assert_raises(FileNotFoundError, method) + assert_raises(IOError, method) def test_ddt_data_name_attribute(): From 2fe5edb190c946f5d2f4d931bc03da410ae16f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:43:53 +0200 Subject: [PATCH 11/17] Refactoring a Python 3 test on ResourceWarning to work around F821 in Python 2 --- test/test_ddt2_specification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test_ddt2_specification.py b/test/test_ddt2_specification.py index 8e059b8..f95bb32 100644 --- a/test/test_ddt2_specification.py +++ b/test/test_ddt2_specification.py @@ -419,11 +419,14 @@ def test(self, *args, **kwargs): def test__file_data_decor_does_not_cause_resource_warning(self): # ResourceWarning does not exist in Python 2 (?) if six.PY3: + # let's violate priniciples of PEP8 to become pep8-compliant :-( + # works around F821 in Python2 + resource_warning = eval('ResourceWarning') with warnings.catch_warnings(record=True) as w: warnings.resetwarnings() # clear all filters warnings.simplefilter('ignore') # ignore all - warnings.simplefilter('always', ResourceWarning) # add filter + warnings.simplefilter('always', resource_warning) # add filter @ddt.ddt class SampleTestCase(object): From a145fa418563f7bc888867f20881c3a12e5d63b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:47:38 +0200 Subject: [PATCH 12/17] Revert "Ignore pep8 F821 fired by guarded Python3 tests executed in Python2" This reverts commit ae0a39377d000ade5f5ef5e4bc63d490e909fe3a. The code has been updated so that the workaround is no longer needed. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 012319a..b8c1edf 100644 --- a/tox.ini +++ b/tox.ini @@ -19,5 +19,5 @@ deps = sphinxcontrib-programoutput commands = nosetests -s --with-coverage --cover-package=ddt --cover-html - flake8 --ignore=F821 ddt.py test + flake8 ddt.py test sphinx-build -b html docs docs/_build From 6ec2c876bf5c400e03f951601d0a87eae15b7b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:53:17 +0200 Subject: [PATCH 13/17] All classes should be new-style objects --- ddt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddt.py b/ddt.py index 77291be..02b752f 100644 --- a/ddt.py +++ b/ddt.py @@ -181,7 +181,7 @@ def test_case_func(self): # Internal data structures -class ParamsFailure: +class ParamsFailure(object): """ FileDataValues generates an instance of this class instead of instances of Params in case it cannot load the data file. A fake test method is @@ -223,7 +223,7 @@ def __add__(self, other): return ParamsFailure(new_name, self.reason) -class Params: +class Params(object): """ Instances of Params form a semigroup with respect to addition. ``Params(None, [], {})`` constitutes the identity. The ``unpack()`` From 358edb92aab238811f62f459f94734620b8551d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 18:56:19 +0200 Subject: [PATCH 14/17] Avoid nested if-statements where if-elif -else does better Thanks @cpennington --- ddt.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ddt.py b/ddt.py index 02b752f..0d17492 100644 --- a/ddt.py +++ b/ddt.py @@ -534,10 +534,9 @@ def combine_names(name1, name2): used as the identity. """ - if name2 is None: + if name1 is None: + return name2 + elif name2 is None: return name1 else: - if name1 is None: - return name2 - else: - return"{0}__{1}".format(name1, name2) + return"{0}__{1}".format(name1, name2) From 46128b5cc48c8ba0a6eb4c7809842dbc115d3031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 19:26:27 +0200 Subject: [PATCH 15/17] Code style: Use comprehensions instead of map() --- ddt.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ddt.py b/ddt.py index 0d17492..0fb92a6 100644 --- a/ddt.py +++ b/ddt.py @@ -142,12 +142,9 @@ def ddt(cls): """ for name, func in list(cls.__dict__.items()): if hasattr(func, PARAMS_SETS_ATTR): - test_vectors = itertools.product(*( - map( - lambda s: s.use_class(cls), - getattr(func, PARAMS_SETS_ATTR) - ) - )) + test_vectors = itertools.product( + *[s.use_class(cls) for s in getattr(func, PARAMS_SETS_ATTR)] + ) for vector in test_vectors: params = sum(vector, Params(name, [], {})) params.unpack(getattr(func, UNPACKALL_ATTR, 0)) @@ -453,7 +450,7 @@ def is_trivial(value): return True if isinstance(value, (list, tuple)): - return all(map(is_trivial, value)) + return all([is_trivial(v) for v in value]) return False From 73830bcda311342597395238e61ee3a99168a7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 19:29:04 +0200 Subject: [PATCH 16/17] Code style: make pylint happier Always time to learn something new. Now I know what all the # pylint comments are good for. :-) --- ddt.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/ddt.py b/ddt.py index 0fb92a6..059cb19 100644 --- a/ddt.py +++ b/ddt.py @@ -168,7 +168,7 @@ def test_case_func(self): return func(self, *params.args, **params.kwargs) else: - def test_case_func(self): + def test_case_func(self): # pylint: disable=unused-argument raise params.reason test_case_func.__name__ = params.name @@ -201,7 +201,7 @@ def __init__(self, name, reason): self.name = name self.reason = reason - def unpack(self, N=1): + def unpack(self, N=1): # pylint: disable=unused-argument """ Do nothing but return self (convenient for use in mappings). @@ -233,14 +233,14 @@ def __init__(self, name, args, kwargs): self.args = args self.kwargs = kwargs - def unpack(self, N=1): + def unpack(self, count=1): """ - Recursively unpack positional arguments `N`-times. + Recursively unpack positional arguments `count`-times. """ - while N > 0: + while count > 0: self.unpack_one_level() - N = N - 1 + count = count - 1 return self def unpack_one_level(self): @@ -319,7 +319,7 @@ def unpack(self): """ self.unpack_count = self.unpack_count + 1 - def use_class(self, cls): + def use_class(self, cls): # pylint: disable=unused-argument """ Does nothing and returns self. @@ -391,13 +391,13 @@ def unpack(self): """ self.unpack_count = self.unpack_count + 1 - def load_data(self): + def load_values(self): # pylint: disable=unused-argument try: filepath = os.path.join(self.pathbase, self.filepath) - with io.open(filepath, encoding=self.encoding) as file: - data = json.load(file) + with io.open(filepath, encoding=self.encoding) as f: + values = json.load(f) - return data + return values except IOError as reason: # IOError is an alias for OSError since Python 3.3 @@ -407,19 +407,19 @@ def load_data(self): return ParamsFailure(reason.__class__.__name__, reason) def __iter__(self): - data = self.load_data() + values = self.load_values() - if isinstance(data, ParamsFailure): - yield data + if isinstance(values, ParamsFailure): + yield values - elif isinstance(data, list): - for idx, value in enumerate(data): + elif isinstance(values, list): + for idx, value in enumerate(values): name = make_params_name(idx, None, value) yield Params(name, [value], {}).unpack(self.unpack_count) - elif isinstance(data, dict): - for idx, key in enumerate(sorted(data)): - value = data[key] + elif isinstance(values, dict): + for idx, key in enumerate(sorted(values)): + value = values[key] name = make_params_name(idx, key, value) yield Params(name, [value], {}).unpack(self.unpack_count) @@ -497,7 +497,7 @@ def convert_to_name(value): # fallback for python2 value = value.encode('ascii', 'backslashreplace') - value = re.sub('\W', '_', value) + value = re.sub('\\W', '_', value) return re.sub('_+', '_', value).strip('_') From 25bf37ce11fef1176d60f174381e1de508b3d218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hole=C4=8Dek?= Date: Thu, 14 May 2015 19:37:22 +0200 Subject: [PATCH 17/17] Code style: Let FileDataValues.load_values() do only one thing ...which is loading a JSON file. Instances of Params are created in FileDavaValues.__iter__(); instances of ParamsFailure should be created there as well. --- ddt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ddt.py b/ddt.py index 059cb19..f941644 100644 --- a/ddt.py +++ b/ddt.py @@ -401,16 +401,16 @@ def load_values(self): # pylint: disable=unused-argument except IOError as reason: # IOError is an alias for OSError since Python 3.3 - return ParamsFailure(reason.__class__.__name__, reason) + return reason except ValueError as reason: - return ParamsFailure(reason.__class__.__name__, reason) + return reason def __iter__(self): values = self.load_values() - if isinstance(values, ParamsFailure): - yield values + if isinstance(values, Exception): + yield ParamsFailure(values.__class__.__name__, values) elif isinstance(values, list): for idx, value in enumerate(values):