Skip to content

Commit

Permalink
parametrize_plus now provides an alternate way to pass argnames, ar…
Browse files Browse the repository at this point in the history
…gvalues and ids. Fixes #106
  • Loading branch information
Sylvain MARIE committed Jun 28, 2020
1 parent 744f052 commit 702fea3
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 36 deletions.
13 changes: 10 additions & 3 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,19 @@ Identical to `param_fixtures` but for a single parameter name, so that you can a
### `@parametrize_plus`

```python
parametrize_plus(argnames, argvalues,
parametrize_plus(argnames=None, argvalues=None,
indirect=False, ids=None, idstyle='explicit',
scope=None, hook=None, debug=False, **kwargs)
idgen=None, scope=None, hook=None, debug=False,
**args)
```

Equivalent to `@pytest.mark.parametrize` but also supports new possibilities in argvalues:
Equivalent to `@pytest.mark.parametrize` but also supports

(1) new style for argnames/argvalues. One can also use `**args` to pass additional `{argnames: argvalues}` in the same parametrization call. This can be handy in combination with `idgen` to master the whole id template associated with several parameters.

(2) new alternate style for ids. One can use `idgen` instead of `ids`. `idgen` can be a callable receiving all parameters at once (`**args`) and returning an id ; or it can be a string template using the new-style string formatting where the argnames can be used as variables (e.g. `idgen=lambda **args: "-".join("%s=%s" % (k, v) for k, v in args.items())` or `idgen="my_id where a={a}"`).

(3) new possibilities in argvalues:

- one can include references to fixtures with `fixture_ref(<fixture>)` where <fixture> can be the fixture name or fixture function. When such a fixture reference is detected in the argvalues, a new function-scope "union" fixture will be created with a unique name, and the test function will be wrapped so as to be injected with the correct parameters from this fixture. Special test ids will be created to illustrate the switching between the various normal parameters and fixtures. You can see debug print messages about all fixtures created using `debug=True`

Expand Down
164 changes: 141 additions & 23 deletions pytest_cases/fixture_parametrize_plus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from distutils.version import LooseVersion
from functools import partial
from inspect import isgeneratorfunction
from itertools import product
from warnings import warn

try: # python 3.3+
Expand All @@ -17,6 +18,7 @@
import pytest
from makefun import with_signature, remove_signature_parameters, add_signature_parameters, wraps

from .common_mini_six import string_types
from .common_pytest import get_fixture_name, remove_duplicates, is_marked_parameter_value, mini_idvalset, \
get_param_argnames_as_list, extract_parameterset_info, ParameterSet, has_pytest_param, get_pytest_marks_on_function, \
transform_marks_into_decorators
Expand Down Expand Up @@ -427,17 +429,29 @@ def get(cls, style # type: str
raise ValueError("Unknown style: %r" % style)


def parametrize_plus(argnames,
argvalues,
def parametrize_plus(argnames=None,
argvalues=None,
indirect=False, # type: bool
ids=None, # type: Union[Callable, List[str]]
idstyle='explicit', # type: str
idgen=None, # type: Union[str, Callable]
scope=None, # type: str
hook=None, # type: Callable[[Callable], Callable]
debug=False, # type: bool
**kwargs):
**args):
"""
Equivalent to `@pytest.mark.parametrize` but also supports new possibilities in argvalues:
Equivalent to `@pytest.mark.parametrize` but also supports
(1) new style for argnames/argvalues. One can also use `**args` to pass additional `{argnames: argvalues}` in the
same parametrization call. This can be handy in combination with `idgen` to master the whole id template associated
with several parameters.
(2) new alternate style for ids. One can use `idgen` instead of `ids`. `idgen` can be a callable receiving all
parameters at once (`**args`) and returning an id ; or it can be a string template using the new-style string
formatting where the argnames can be used as variables (e.g.
`idgen=lambda **args: "-".join("%s=%s" % (k, v) for k, v in args.items())` or `idgen="my_id where a={a}"`).
(3) new possibilities in argvalues:
- one can include references to fixtures with `fixture_ref(<fixture>)` where <fixture> can be the fixture name or
fixture function. When such a fixture reference is detected in the argvalues, a new function-scope "union" fixture
Expand All @@ -462,19 +476,23 @@ def parametrize_plus(argnames,
:param argnames: same as in pytest.mark.parametrize
:param argvalues: same as in pytest.mark.parametrize except that `fixture_ref` and `lazy_value` are supported
:param indirect: same as in pytest.mark.parametrize
:param ids: same as in pytest.mark.parametrize
:param ids: same as in pytest.mark.parametrize. Note that an alternative way to create ids exists with `idgen`. Only
one non-None `ids` or `idgen should be provided.
:param idgen: an id formatter. Either a string representing a template, or a callable receiving all argvalues
at once (as opposed to the behaviour in pytest ids). This alternative way to generate ids can only be used when
`ids` is not provided (None).
:param idstyle: style of ids to be used in generated "union" fixtures. See `fixture_union` for details.
:param scope: same as in pytest.mark.parametrize
:param hook: an optional hook to apply to each fixture function that is created during this call. The hook function
will be called everytime a fixture is about to be created. It will receive a single argument (the function
implementing the fixture) and should return the function to use. For example you can use `saved_fixture` from
`pytest-harvest` as a hook in order to save all such created fixtures in the fixture store.
:param debug: print debug messages on stdout to analyze fixture creation (use pytest -s to see them)
:param kwargs: additional arguments for pytest.mark.parametrize
:param args: additional {argnames: argvalues} definition
:return:
"""
return _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, idstyle=idstyle, scope=scope, hook=hook,
debug=debug, **kwargs)
return _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, idgen=idgen, idstyle=idstyle, scope=scope,
hook=hook, debug=debug, **args)


def handle_lazy_args(argval):
Expand Down Expand Up @@ -562,25 +580,47 @@ def get(self):
return self.value


def _parametrize_plus(argnames,
argvalues,
class InvalidIdTemplateException(Exception):
"""
Raised when a string template provided in an `idgen` raises an error
"""
def __init__(self, idgen, params, caught):
self.idgen = idgen
self.params = params
self.caught = caught
super(InvalidIdTemplateException, self).__init__()

def __str__(self):
return repr(self)

def __repr__(self):
return "Error generating test id using name template '%s' with parameter values " \
"%r. Please check the name template. Caught: %s - %s" \
% (self.idgen, self.params, self.caught.__class__, self.caught)


def _parametrize_plus(argnames=None,
argvalues=None,
indirect=False, # type: bool
ids=None, # type: Union[Callable, List[str]]
idstyle='explicit', # type: str
idgen=None, # type: Union[str, Callable]
scope=None, # type: str
hook=None, # type: Callable[[Callable], Callable]
_frame_offset=2,
debug=False, # type: bool
**kwargs):
# make sure that we do not destroy the argvalues if it is provided as an iterator
try:
argvalues = list(argvalues)
except TypeError:
raise InvalidParamsList(argvalues)
**args):

# first handle argnames / argvalues (new modes of input)
argnames, argvalues = _get_argnames_argvalues(argnames, argvalues, **args)

if idgen is not None:
if ids is not None:
raise ValueError("Only one of `ids` and `idgen` should be provided")
ids = _gen_ids(argnames, argvalues, idgen)

# get the param names
initial_argnames = argnames
argnames = get_param_argnames_as_list(argnames)
# argnames related
initial_argnames = ','.join(argnames)
nb_params = len(argnames)

# extract all marks and custom ids.
Expand All @@ -594,7 +634,7 @@ def _parametrize_plus(argnames,

# no fixture reference: shortcut, do as usual (note that the hook wont be called since no fixture is created)
_decorator = pytest.mark.parametrize(initial_argnames, marked_argvalues, indirect=indirect,
ids=ids, scope=scope, **kwargs)
ids=ids, scope=scope)
if indirect:
return _decorator
else:
Expand All @@ -614,9 +654,6 @@ def _apply(test_func):
raise ValueError("Setting `indirect=True` is not yet supported when at least a `fixure_ref` is present in "
"the `argvalues`.")

if len(kwargs) > 0:
warn("Unsupported kwargs for `parametrize_plus`: %r" % kwargs)

if debug:
print("Fixture references found. Creating fixtures...")

Expand Down Expand Up @@ -946,6 +983,87 @@ def wrapped_test_func(*args, **kwargs): # noqa
return parametrize_plus_decorate


def _get_argnames_argvalues(argnames=None, argvalues=None, **args):
"""
:param argnames:
:param argvalues:
:param args:
:return: argnames, argvalues - both guaranteed to be lists
"""
# handle **args - a dict of {argnames: argvalues}
if len(args) < 2:
kw_argnames = get_param_argnames_as_list(next(iter(args.keys()))) if len(args) > 0 else []
kw_argvalues = list(*args.values())
else:
kw_argvalues = list(product(*args.values()))
kw_argnames = []
for i, argname in enumerate(args.keys()):
_names = get_param_argnames_as_list(argname)
kw_argnames += _names
if len(_names) > 1:
for j, _argvals in enumerate(kw_argvalues):
kw_argvalues[j] = kw_argvalues[j][:i] + kw_argvalues[j][i] + kw_argvalues[j][i+1:]

if argnames is None:
# (1) all {argnames: argvalues} pairs are provided in **args
if argvalues is not None or len(args) == 0:
raise ValueError("No parameters provided")

argnames = kw_argnames
argvalues = kw_argvalues

elif isinstance(argnames, string_types):
# (2) argnames + argvalues, as usual. However **args can also be passed and should be added
argnames = get_param_argnames_as_list(argnames)

if argvalues is None:
raise ValueError("No argvalues provided while argnames are provided")

# transform argvalues to a list (it can be a generator)
try:
argvalues = list(argvalues)
except TypeError:
raise InvalidParamsList(argvalues)

# append **args
if len(kw_argnames) > 0:
argnames.extend(kw_argnames)
argvalues = list(product(argvalues, *kw_argvalues))

return argnames, argvalues


def _gen_ids(argnames, argvalues, idgen):
"""
Generates an explicit test ids list from a non-none `idgen`.
`idgen` should be either a callable of a string template.
:param argnames:
:param argvalues:
:param idgen:
:return:
"""
if not callable(idgen):
_formatter = idgen

def gen_id_using_str_formatter(**params):
try:
return _formatter.format(**params)
except Exception as e:
raise InvalidIdTemplateException(_formatter, params, e)

idgen = gen_id_using_str_formatter
if len(argnames) > 1:
ids = [idgen(**{n: v for n, v in zip(argnames, _argvals)}) for _argvals in argvalues]
else:
_only_name = argnames[0]
ids = [idgen(**{_only_name: v}) for v in argvalues]

return ids


def _process_argvalues(argnames, marked_argvalues, nb_params):
"""Internal method to use in _pytest_parametrize_plus
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import sys

import pytest

from pytest_harvest import get_session_synthesis_dct

from pytest_cases import parametrize_plus
from pytest_cases.fixture_parametrize_plus import _get_argnames_argvalues


def test_argname_error():
with pytest.raises(ValueError, match="parameter 'a' not found in test function signature"):
@parametrize_plus("a", [True])
def test_foo(b):
pass


@pytest.mark.parametrize("tuple_around_single", [False, True])
def test_get_argnames_argvalues(tuple_around_single):

# legacy way
# -- 1 argname
argnames, argvalues = _get_argnames_argvalues('a', (True, 1.25))
assert argnames == ['a']
assert argvalues == [True, 1.25]
# -- several argnames
argnames, argvalues = _get_argnames_argvalues('a,b', ((True, 1.25), (True, 0)))
assert argnames == ['a', 'b']
assert argvalues == [(True, 1.25), (True, 0)]

# **args only
# -- 1 argname
argnames, argvalues = _get_argnames_argvalues(b=[1.25, 0])
assert argnames == ['b']
assert argvalues == [1.25, 0]
# -- several argnames
argnames, argvalues = _get_argnames_argvalues(a=[True], b=[1.25, 0])
assert argnames == ['a', 'b']
assert argvalues == [(True, 1.25), (True, 0)]
# --dict version
# -- 1 argname
argnames, argvalues = _get_argnames_argvalues(**{'b': [1.25, 0]})
assert argnames == ['b']
assert argvalues == [1.25, 0]
# -- several argnames at once
argnames, argvalues = _get_argnames_argvalues(**{'a,b': ((True, 1.25), (True, 0))})
assert argnames == ['a', 'b']
assert argvalues == [(True, 1.25), (True, 0)]
# -- several argnames in two entries
argnames, argvalues = _get_argnames_argvalues(**{'a,b': ((True, 1.25), (True, 0)), 'c': [-1, 2]})
if sys.version_info < (3, 6):
# order is lost
assert set(argnames) == {'a', 'b', 'c'}
else:
assert argnames == ['a', 'b', 'c']

if argnames[-1] == 'c':
assert argvalues == [(True, 1.25, -1), (True, 1.25, 2), (True, 0, -1), (True, 0, 2)]
else:
# for python < 3.6
assert argvalues == [(-1, True, 1.25), (-1, True, 0), (2, True, 1.25), (2, True, 0)]


def format_me(**kwargs):
if 'a' in kwargs:
return "a={a},b={b:3d}".format(**kwargs)
else:
return "{d}yes".format(**kwargs)


@parametrize_plus("a,b", [(True, -1), (False, 3)], idgen=format_me)
@parametrize_plus("c", [2.1, 0.], idgen="c{c:.1f}")
@parametrize_plus("d", [10], idgen=format_me)
def test_idgen1(a, b, c, d):
pass


def test_idgen1_synthesis(request):
results_dct = get_session_synthesis_dct(request, filter=test_idgen1, test_id_format='function')
if sys.version_info > (3, 6):
assert list(results_dct) == [
'test_idgen1[10yes-c2.1-a=True,b= -1]',
'test_idgen1[10yes-c2.1-a=False,b= 3]',
'test_idgen1[10yes-c0.0-a=True,b= -1]',
'test_idgen1[10yes-c0.0-a=False,b= 3]'
]
else:
assert len(results_dct) == 4


@parametrize_plus(idgen="a={a},b={b:.1f} and {c:4d}", **{'a,b': ((True, 1.25), (True, 0.)), 'c': [-1, 2]})
def test_alt_usage1(a, b, c):
pass


def test_alt_usage1_synthesis(request):
results_dct = get_session_synthesis_dct(request, filter=test_alt_usage1, test_id_format='function')
if sys.version_info > (3, 6):
assert list(results_dct) == [
'test_alt_usage1[a=True,b=1.2 and -1]',
'test_alt_usage1[a=True,b=1.2 and 2]',
'test_alt_usage1[a=True,b=0.0 and -1]',
'test_alt_usage1[a=True,b=0.0 and 2]'
]
else:
assert len(results_dct) == 4


@parametrize_plus(idgen="b{b:.1}", **{'b': (1.25, 0.)})
def test_alt_usage2(b):
pass


def test_alt_usage2_synthesis(request):
results_dct = get_session_synthesis_dct(request, filter=test_alt_usage2, test_id_format='function')
assert list(results_dct) == [
'test_alt_usage2[b1e+00]',
'test_alt_usage2[b0e+00]'
]

This file was deleted.

0 comments on commit 702fea3

Please sign in to comment.