diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2019-10-17 13:09:24 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2019-10-20 20:49:03 -0400 |
commit | ed553fffd65a063d6dbdb3770d1fa0124bd55e23 (patch) | |
tree | 59ab8a457b3ed82cb7647b7da1b94b4ce2a815e1 /lib/sqlalchemy/testing/plugin/pytestplugin.py | |
parent | 528782d1c356445f17cea857ef0974e074c51d60 (diff) | |
download | sqlalchemy-ed553fffd65a063d6dbdb3770d1fa0124bd55e23.tar.gz |
Implement facade for pytest parametrize, fixtures, classlevel
Add factilities to implement pytest.mark.parametrize and
pytest.fixtures patterns, which largely resemble things we are
already doing.
Ensure a facade is used, so that the test suite remains independent
of py.test, but also tailors the functions to the more limited
scope in which we are using them.
Additionally, create a class-based version that works from the
same facade.
Several old polymorphic tests as well as two of the sql test
are refactored to use the new features.
Change-Id: I6ef8af1dafff92534313016944d447f9439856cf
References: #4896
Diffstat (limited to 'lib/sqlalchemy/testing/plugin/pytestplugin.py')
-rw-r--r-- | lib/sqlalchemy/testing/plugin/pytestplugin.py | 151 |
1 files changed, 147 insertions, 4 deletions
diff --git a/lib/sqlalchemy/testing/plugin/pytestplugin.py b/lib/sqlalchemy/testing/plugin/pytestplugin.py index e0335c135..5d91db5d7 100644 --- a/lib/sqlalchemy/testing/plugin/pytestplugin.py +++ b/lib/sqlalchemy/testing/plugin/pytestplugin.py @@ -8,7 +8,11 @@ except ImportError: import argparse import collections import inspect +import itertools +import operator import os +import re +import sys import pytest @@ -87,7 +91,7 @@ def pytest_configure(config): bool(getattr(config.option, "cov_source", False)) ) - plugin_base.set_skip_test(pytest.skip.Exception) + plugin_base.set_fixture_functions(PytestFixtureFunctions) def pytest_sessionstart(session): @@ -132,6 +136,7 @@ def pytest_collection_modifyitems(session, config, items): rebuilt_items = collections.defaultdict( lambda: collections.defaultdict(list) ) + items[:] = [ item for item in items @@ -173,21 +178,63 @@ def pytest_collection_modifyitems(session, config, items): def pytest_pycollect_makeitem(collector, name, obj): - if inspect.isclass(obj) and plugin_base.want_class(obj): - return pytest.Class(name, parent=collector) + + if inspect.isclass(obj) and plugin_base.want_class(name, obj): + return [ + pytest.Class(parametrize_cls.__name__, parent=collector) + for parametrize_cls in _parametrize_cls(collector.module, obj) + ] elif ( inspect.isfunction(obj) and isinstance(collector, pytest.Instance) and plugin_base.want_method(collector.cls, obj) ): - return pytest.Function(name, parent=collector) + # None means, fall back to default logic, which includes + # method-level parametrize + return None else: + # empty list means skip this item return [] _current_class = None +def _parametrize_cls(module, cls): + """implement a class-based version of pytest parametrize.""" + + if "_sa_parametrize" not in cls.__dict__: + return [cls] + + _sa_parametrize = cls._sa_parametrize + classes = [] + for full_param_set in itertools.product( + *[params for argname, params in _sa_parametrize] + ): + cls_variables = {} + + for argname, param in zip( + [_sa_param[0] for _sa_param in _sa_parametrize], full_param_set + ): + if not argname: + raise TypeError("need argnames for class-based combinations") + argname_split = re.split(r",\s*", argname) + for arg, val in zip(argname_split, param.values): + cls_variables[arg] = val + parametrized_name = "_".join( + # token is a string, but in py2k py.test is giving us a unicode, + # so call str() on it. + str(re.sub(r"\W", "", token)) + for param in full_param_set + for token in param.id.split("-") + ) + name = "%s_%s" % (cls.__name__, parametrized_name) + newcls = type.__new__(type, name, (cls,), cls_variables) + setattr(module, name, newcls) + classes.append(newcls) + return classes + + def pytest_runtest_setup(item): # here we seem to get called only based on what we collected # in pytest_collection_modifyitems. So to do class-based stuff @@ -239,3 +286,99 @@ def class_setup(item): def class_teardown(item): plugin_base.stop_test_class(item.cls) + + +def getargspec(fn): + if sys.version_info.major == 3: + return inspect.getfullargspec(fn) + else: + return inspect.getargspec(fn) + + +class PytestFixtureFunctions(plugin_base.FixtureFunctions): + def skip_test_exception(self, *arg, **kw): + return pytest.skip.Exception(*arg, **kw) + + _combination_id_fns = { + "i": lambda obj: obj, + "r": repr, + "s": str, + "n": operator.attrgetter("__name__"), + } + + def combinations(self, *arg_sets, **kw): + """facade for pytest.mark.paramtrize. + + Automatically derives argument names from the callable which in our + case is always a method on a class with positional arguments. + + ids for parameter sets are derived using an optional template. + + """ + + if sys.version_info.major == 3: + if len(arg_sets) == 1 and hasattr(arg_sets[0], "__next__"): + arg_sets = list(arg_sets[0]) + else: + if len(arg_sets) == 1 and hasattr(arg_sets[0], "next"): + arg_sets = list(arg_sets[0]) + + argnames = kw.pop("argnames", None) + + id_ = kw.pop("id_", None) + + if id_: + _combination_id_fns = self._combination_id_fns + + # because itemgetter is not consistent for one argument vs. + # multiple, make it multiple in all cases and use a slice + # to omit the first argument + _arg_getter = operator.itemgetter( + 0, + *[ + idx + for idx, char in enumerate(id_) + if char in ("n", "r", "s", "a") + ] + ) + fns = [ + (operator.itemgetter(idx), _combination_id_fns[char]) + for idx, char in enumerate(id_) + if char in _combination_id_fns + ] + arg_sets = [ + pytest.param( + *_arg_getter(arg)[1:], + id="-".join( + comb_fn(getter(arg)) for getter, comb_fn in fns + ) + ) + for arg in arg_sets + ] + else: + # ensure using pytest.param so that even a 1-arg paramset + # still needs to be a tuple. otherwise paramtrize tries to + # interpret a single arg differently than tuple arg + arg_sets = [pytest.param(*arg) for arg in arg_sets] + + def decorate(fn): + if inspect.isclass(fn): + if "_sa_parametrize" not in fn.__dict__: + fn._sa_parametrize = [] + fn._sa_parametrize.append((argnames, arg_sets)) + return fn + else: + if argnames is None: + _argnames = getargspec(fn).args[1:] + else: + _argnames = argnames + return pytest.mark.parametrize(_argnames, arg_sets)(fn) + + return decorate + + def param_ident(self, *parameters): + ident = parameters[0] + return pytest.param(*parameters[1:], id=ident) + + def fixture(self, fn): + return pytest.fixture(fn) |