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 | |
| 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')
| -rw-r--r-- | lib/sqlalchemy/testing/plugin/plugin_base.py | 50 | ||||
| -rw-r--r-- | lib/sqlalchemy/testing/plugin/pytestplugin.py | 151 |
2 files changed, 185 insertions, 16 deletions
diff --git a/lib/sqlalchemy/testing/plugin/plugin_base.py b/lib/sqlalchemy/testing/plugin/plugin_base.py index 859d1d779..a2f969a66 100644 --- a/lib/sqlalchemy/testing/plugin/plugin_base.py +++ b/lib/sqlalchemy/testing/plugin/plugin_base.py @@ -16,6 +16,7 @@ is py.test. from __future__ import absolute_import +import abc import re import sys @@ -24,8 +25,15 @@ py3k = sys.version_info >= (3, 0) if py3k: import configparser + + ABC = abc.ABC else: import ConfigParser as configparser + import collections as collections_abc # noqa + + class ABC(object): + __metaclass__ = abc.ABCMeta + # late imports fixtures = None @@ -238,14 +246,6 @@ def set_coverage_flag(value): options.has_coverage = value -_skip_test_exception = None - - -def set_skip_test(exc): - global _skip_test_exception - _skip_test_exception = exc - - def post_begin(): """things to set up later, once we know coverage is running.""" # Lazy setup of other options (post coverage) @@ -331,10 +331,10 @@ def _monkeypatch_cdecimal(options, file_config): @post -def _init_skiptest(options, file_config): +def _init_symbols(options, file_config): from sqlalchemy.testing import config - config._skip_test_exception = _skip_test_exception + config._fixture_functions = _fixture_fn_class() @post @@ -486,10 +486,10 @@ def _setup_profiling(options, file_config): ) -def want_class(cls): +def want_class(name, cls): if not issubclass(cls, fixtures.TestBase): return False - elif cls.__name__.startswith("_"): + elif name.startswith("_"): return False elif ( config.options.backend_only @@ -711,3 +711,29 @@ def _do_skips(cls): def _setup_config(config_obj, ctx): config._current.push(config_obj, testing) + + +class FixtureFunctions(ABC): + @abc.abstractmethod + def skip_test_exception(self, *arg, **kw): + raise NotImplementedError() + + @abc.abstractmethod + def combinations(self, *args, **kw): + raise NotImplementedError() + + @abc.abstractmethod + def param_ident(self, *args, **kw): + raise NotImplementedError() + + @abc.abstractmethod + def fixture(self, fn): + raise NotImplementedError() + + +_fixture_fn_class = None + + +def set_fixture_functions(fixture_fn_class): + global _fixture_fn_class + _fixture_fn_class = fixture_fn_class 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) |
