Merge pull request #6174 from blueyed/ids-iter
parametrized: ids: support generator/iterator
This commit is contained in:
commit
ed012c808a
|
@ -0,0 +1 @@
|
|||
``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings.
|
|
@ -0,0 +1 @@
|
|||
``pytest.mark.parametrize`` supports iterators and generators for ``ids``.
|
|
@ -2,6 +2,8 @@ import inspect
|
|||
import warnings
|
||||
from collections import namedtuple
|
||||
from collections.abc import MutableMapping
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Set
|
||||
|
||||
import attr
|
||||
|
@ -144,7 +146,15 @@ class Mark:
|
|||
#: keyword arguments of the mark decorator
|
||||
kwargs = attr.ib() # Dict[str, object]
|
||||
|
||||
def combined_with(self, other):
|
||||
#: source Mark for ids with parametrize Marks
|
||||
_param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False)
|
||||
#: resolved/generated ids with parametrize Marks
|
||||
_param_ids_generated = attr.ib(type=Optional[List[str]], default=None, repr=False)
|
||||
|
||||
def _has_param_ids(self):
|
||||
return "ids" in self.kwargs or len(self.args) >= 4
|
||||
|
||||
def combined_with(self, other: "Mark") -> "Mark":
|
||||
"""
|
||||
:param other: the mark to combine with
|
||||
:type other: Mark
|
||||
|
@ -153,8 +163,20 @@ class Mark:
|
|||
combines by appending args and merging the mappings
|
||||
"""
|
||||
assert self.name == other.name
|
||||
|
||||
# Remember source of ids with parametrize Marks.
|
||||
param_ids_from = None # type: Optional[Mark]
|
||||
if self.name == "parametrize":
|
||||
if other._has_param_ids():
|
||||
param_ids_from = other
|
||||
elif self._has_param_ids():
|
||||
param_ids_from = self
|
||||
|
||||
return Mark(
|
||||
self.name, self.args + other.args, dict(self.kwargs, **other.kwargs)
|
||||
self.name,
|
||||
self.args + other.args,
|
||||
dict(self.kwargs, **other.kwargs),
|
||||
param_ids_from=param_ids_from,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ from collections.abc import Sequence
|
|||
from functools import partial
|
||||
from textwrap import dedent
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
import py
|
||||
|
@ -36,6 +37,7 @@ from _pytest.deprecated import FUNCARGNAMES
|
|||
from _pytest.main import FSHookProxy
|
||||
from _pytest.mark import MARK_GEN
|
||||
from _pytest.mark.structures import get_unpacked_marks
|
||||
from _pytest.mark.structures import Mark
|
||||
from _pytest.mark.structures import normalize_mark_list
|
||||
from _pytest.outcomes import fail
|
||||
from _pytest.outcomes import skip
|
||||
|
@ -122,7 +124,7 @@ def pytest_cmdline_main(config):
|
|||
|
||||
def pytest_generate_tests(metafunc):
|
||||
for marker in metafunc.definition.iter_markers(name="parametrize"):
|
||||
metafunc.parametrize(*marker.args, **marker.kwargs)
|
||||
metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
|
@ -914,7 +916,16 @@ class Metafunc:
|
|||
warnings.warn(FUNCARGNAMES, stacklevel=2)
|
||||
return self.fixturenames
|
||||
|
||||
def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None):
|
||||
def parametrize(
|
||||
self,
|
||||
argnames,
|
||||
argvalues,
|
||||
indirect=False,
|
||||
ids=None,
|
||||
scope=None,
|
||||
*,
|
||||
_param_mark: Optional[Mark] = None
|
||||
):
|
||||
""" Add new invocations to the underlying test function using the list
|
||||
of argvalues for the given argnames. Parametrization is performed
|
||||
during the collection phase. If you need to setup expensive resources
|
||||
|
@ -937,13 +948,22 @@ class Metafunc:
|
|||
function so that it can perform more expensive setups during the
|
||||
setup phase of a test rather than at collection time.
|
||||
|
||||
:arg ids: list of string ids, or a callable.
|
||||
If strings, each is corresponding to the argvalues so that they are
|
||||
part of the test id. If None is given as id of specific test, the
|
||||
automatically generated id for that argument will be used.
|
||||
If callable, it should take one argument (a single argvalue) and return
|
||||
a string or return None. If None, the automatically generated id for that
|
||||
argument will be used.
|
||||
:arg ids: sequence of (or generator for) ids for ``argvalues``,
|
||||
or a callable to return part of the id for each argvalue.
|
||||
|
||||
With sequences (and generators like ``itertools.count()``) the
|
||||
returned ids should be of type ``string``, ``int``, ``float``,
|
||||
``bool``, or ``None``.
|
||||
They are mapped to the corresponding index in ``argvalues``.
|
||||
``None`` means to use the auto-generated id.
|
||||
|
||||
If it is a callable it will be called for each entry in
|
||||
``argvalues``, and the return value is used as part of the
|
||||
auto-generated id for the whole set (where parts are joined with
|
||||
dashes ("-")).
|
||||
This is useful to provide more specific ids for certain items, e.g.
|
||||
dates. Returning ``None`` will use an auto-generated id.
|
||||
|
||||
If no ids are provided they will be generated automatically from
|
||||
the argvalues.
|
||||
|
||||
|
@ -977,8 +997,18 @@ class Metafunc:
|
|||
|
||||
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
|
||||
|
||||
# Use any already (possibly) generated ids with parametrize Marks.
|
||||
if _param_mark and _param_mark._param_ids_from:
|
||||
generated_ids = _param_mark._param_ids_from._param_ids_generated
|
||||
if generated_ids is not None:
|
||||
ids = generated_ids
|
||||
|
||||
ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition)
|
||||
|
||||
# Store used (possibly generated) ids with parametrize Marks.
|
||||
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
|
||||
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
|
||||
|
||||
scopenum = scope2index(
|
||||
scope, descr="parametrize() call in {}".format(self.function.__name__)
|
||||
)
|
||||
|
@ -1013,27 +1043,48 @@ class Metafunc:
|
|||
:rtype: List[str]
|
||||
:return: the list of ids for each argname given
|
||||
"""
|
||||
from _pytest._io.saferepr import saferepr
|
||||
|
||||
idfn = None
|
||||
if callable(ids):
|
||||
idfn = ids
|
||||
ids = None
|
||||
if ids:
|
||||
func_name = self.function.__name__
|
||||
if len(ids) != len(parameters):
|
||||
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
|
||||
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
|
||||
for id_value in ids:
|
||||
if id_value is not None and not isinstance(id_value, str):
|
||||
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
|
||||
fail(
|
||||
msg.format(func_name, saferepr(id_value), type(id_value)),
|
||||
pytrace=False,
|
||||
)
|
||||
ids = self._validate_ids(ids, parameters, func_name)
|
||||
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
|
||||
return ids
|
||||
|
||||
def _validate_ids(self, ids, parameters, func_name):
|
||||
try:
|
||||
len(ids)
|
||||
except TypeError:
|
||||
try:
|
||||
it = iter(ids)
|
||||
except TypeError:
|
||||
raise TypeError("ids must be a callable, sequence or generator")
|
||||
else:
|
||||
import itertools
|
||||
|
||||
new_ids = list(itertools.islice(it, len(parameters)))
|
||||
else:
|
||||
new_ids = list(ids)
|
||||
|
||||
if len(new_ids) != len(parameters):
|
||||
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
|
||||
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
|
||||
for idx, id_value in enumerate(new_ids):
|
||||
if id_value is not None:
|
||||
if isinstance(id_value, (float, int, bool)):
|
||||
new_ids[idx] = str(id_value)
|
||||
elif not isinstance(id_value, str):
|
||||
from _pytest._io.saferepr import saferepr
|
||||
|
||||
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
|
||||
fail(
|
||||
msg.format(func_name, saferepr(id_value), type(id_value), idx),
|
||||
pytrace=False,
|
||||
)
|
||||
return new_ids
|
||||
|
||||
def _resolve_arg_value_types(self, argnames, indirect):
|
||||
"""Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg"
|
||||
to the function, based on the ``indirect`` parameter of the parametrized() call.
|
||||
|
|
|
@ -9,6 +9,7 @@ from hypothesis import strategies
|
|||
import pytest
|
||||
from _pytest import fixtures
|
||||
from _pytest import python
|
||||
from _pytest.outcomes import fail
|
||||
from _pytest.python import _idval
|
||||
|
||||
|
||||
|
@ -62,6 +63,39 @@ class TestMetafunc:
|
|||
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
|
||||
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
|
||||
|
||||
with pytest.raises(
|
||||
TypeError, match="^ids must be a callable, sequence or generator$"
|
||||
):
|
||||
metafunc.parametrize("y", [5, 6], ids=42)
|
||||
|
||||
def test_parametrize_error_iterator(self):
|
||||
def func(x):
|
||||
raise NotImplementedError()
|
||||
|
||||
class Exc(Exception):
|
||||
def __repr__(self):
|
||||
return "Exc(from_gen)"
|
||||
|
||||
def gen():
|
||||
yield 0
|
||||
yield None
|
||||
yield Exc()
|
||||
|
||||
metafunc = self.Metafunc(func)
|
||||
metafunc.parametrize("x", [1, 2], ids=gen())
|
||||
assert [(x.funcargs, x.id) for x in metafunc._calls] == [
|
||||
({"x": 1}, "0"),
|
||||
({"x": 2}, "2"),
|
||||
]
|
||||
with pytest.raises(
|
||||
fail.Exception,
|
||||
match=(
|
||||
r"In func: ids must be list of string/float/int/bool, found:"
|
||||
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2"
|
||||
),
|
||||
):
|
||||
metafunc.parametrize("x", [1, 2, 3], ids=gen())
|
||||
|
||||
def test_parametrize_bad_scope(self, testdir):
|
||||
def func(x):
|
||||
pass
|
||||
|
@ -168,6 +202,26 @@ class TestMetafunc:
|
|||
("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"]
|
||||
)
|
||||
|
||||
def test_parametrize_ids_iterator_without_mark(self):
|
||||
import itertools
|
||||
|
||||
def func(x, y):
|
||||
pass
|
||||
|
||||
it = itertools.count()
|
||||
|
||||
metafunc = self.Metafunc(func)
|
||||
metafunc.parametrize("x", [1, 2], ids=it)
|
||||
metafunc.parametrize("y", [3, 4], ids=it)
|
||||
ids = [x.id for x in metafunc._calls]
|
||||
assert ids == ["0-2", "0-3", "1-2", "1-3"]
|
||||
|
||||
metafunc = self.Metafunc(func)
|
||||
metafunc.parametrize("x", [1, 2], ids=it)
|
||||
metafunc.parametrize("y", [3, 4], ids=it)
|
||||
ids = [x.id for x in metafunc._calls]
|
||||
assert ids == ["4-6", "4-7", "5-6", "5-7"]
|
||||
|
||||
def test_parametrize_empty_list(self):
|
||||
"""#510"""
|
||||
|
||||
|
@ -527,9 +581,22 @@ class TestMetafunc:
|
|||
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
|
||||
def test(arg):
|
||||
assert arg
|
||||
|
||||
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
|
||||
def test_int(arg):
|
||||
assert arg
|
||||
"""
|
||||
)
|
||||
assert testdir.runpytest().ret == 0
|
||||
result = testdir.runpytest("-vv", "-s")
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
|
||||
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
|
||||
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
|
||||
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
|
||||
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
|
||||
]
|
||||
)
|
||||
|
||||
def test_idmaker_with_ids(self):
|
||||
from _pytest.python import idmaker
|
||||
|
@ -1179,12 +1246,12 @@ class TestMetafuncFunctional:
|
|||
result.stdout.fnmatch_lines(["* 1 skipped *"])
|
||||
|
||||
def test_parametrized_ids_invalid_type(self, testdir):
|
||||
"""Tests parametrized with ids as non-strings (#1857)."""
|
||||
"""Test error with non-strings/non-ints, without generator (#1857)."""
|
||||
testdir.makepyfile(
|
||||
"""
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
|
||||
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
|
||||
def test_ids_numbers(x,expected):
|
||||
assert x * 2 == expected
|
||||
"""
|
||||
|
@ -1192,7 +1259,8 @@ class TestMetafuncFunctional:
|
|||
result = testdir.runpytest()
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
|
||||
"In test_ids_numbers: ids must be list of string/float/int/bool,"
|
||||
" found: <class 'type'> (type: <class 'type'>) at index 2"
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -1773,3 +1841,39 @@ class TestMarkersWithParametrization:
|
|||
)
|
||||
result = testdir.runpytest()
|
||||
result.assert_outcomes(passed=1)
|
||||
|
||||
def test_parametrize_iterator(self, testdir):
|
||||
testdir.makepyfile(
|
||||
"""
|
||||
import itertools
|
||||
import pytest
|
||||
|
||||
id_parametrize = pytest.mark.parametrize(
|
||||
ids=("param%d" % i for i in itertools.count())
|
||||
)
|
||||
|
||||
@id_parametrize('y', ['a', 'b'])
|
||||
def test1(y):
|
||||
pass
|
||||
|
||||
@id_parametrize('y', ['a', 'b'])
|
||||
def test2(y):
|
||||
pass
|
||||
|
||||
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count())
|
||||
def test_converted_to_str(a, b):
|
||||
pass
|
||||
"""
|
||||
)
|
||||
result = testdir.runpytest("-vv", "-s")
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
"test_parametrize_iterator.py::test1[param0] PASSED",
|
||||
"test_parametrize_iterator.py::test1[param1] PASSED",
|
||||
"test_parametrize_iterator.py::test2[param0] PASSED",
|
||||
"test_parametrize_iterator.py::test2[param1] PASSED",
|
||||
"test_parametrize_iterator.py::test_converted_to_str[0] PASSED",
|
||||
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
|
||||
"*= 6 passed in *",
|
||||
]
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue