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
|
import warnings
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from collections.abc import MutableMapping
|
from collections.abc import MutableMapping
|
||||||
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
@ -144,7 +146,15 @@ class Mark:
|
||||||
#: keyword arguments of the mark decorator
|
#: keyword arguments of the mark decorator
|
||||||
kwargs = attr.ib() # Dict[str, object]
|
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
|
:param other: the mark to combine with
|
||||||
:type other: Mark
|
:type other: Mark
|
||||||
|
@ -153,8 +163,20 @@ class Mark:
|
||||||
combines by appending args and merging the mappings
|
combines by appending args and merging the mappings
|
||||||
"""
|
"""
|
||||||
assert self.name == other.name
|
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(
|
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 functools import partial
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import py
|
import py
|
||||||
|
@ -36,6 +37,7 @@ from _pytest.deprecated import FUNCARGNAMES
|
||||||
from _pytest.main import FSHookProxy
|
from _pytest.main import FSHookProxy
|
||||||
from _pytest.mark import MARK_GEN
|
from _pytest.mark import MARK_GEN
|
||||||
from _pytest.mark.structures import get_unpacked_marks
|
from _pytest.mark.structures import get_unpacked_marks
|
||||||
|
from _pytest.mark.structures import Mark
|
||||||
from _pytest.mark.structures import normalize_mark_list
|
from _pytest.mark.structures import normalize_mark_list
|
||||||
from _pytest.outcomes import fail
|
from _pytest.outcomes import fail
|
||||||
from _pytest.outcomes import skip
|
from _pytest.outcomes import skip
|
||||||
|
@ -122,7 +124,7 @@ def pytest_cmdline_main(config):
|
||||||
|
|
||||||
def pytest_generate_tests(metafunc):
|
def pytest_generate_tests(metafunc):
|
||||||
for marker in metafunc.definition.iter_markers(name="parametrize"):
|
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):
|
def pytest_configure(config):
|
||||||
|
@ -914,7 +916,16 @@ class Metafunc:
|
||||||
warnings.warn(FUNCARGNAMES, stacklevel=2)
|
warnings.warn(FUNCARGNAMES, stacklevel=2)
|
||||||
return self.fixturenames
|
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
|
""" Add new invocations to the underlying test function using the list
|
||||||
of argvalues for the given argnames. Parametrization is performed
|
of argvalues for the given argnames. Parametrization is performed
|
||||||
during the collection phase. If you need to setup expensive resources
|
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
|
function so that it can perform more expensive setups during the
|
||||||
setup phase of a test rather than at collection time.
|
setup phase of a test rather than at collection time.
|
||||||
|
|
||||||
:arg ids: list of string ids, or a callable.
|
:arg ids: sequence of (or generator for) ids for ``argvalues``,
|
||||||
If strings, each is corresponding to the argvalues so that they are
|
or a callable to return part of the id for each argvalue.
|
||||||
part of the test id. If None is given as id of specific test, the
|
|
||||||
automatically generated id for that argument will be used.
|
With sequences (and generators like ``itertools.count()``) the
|
||||||
If callable, it should take one argument (a single argvalue) and return
|
returned ids should be of type ``string``, ``int``, ``float``,
|
||||||
a string or return None. If None, the automatically generated id for that
|
``bool``, or ``None``.
|
||||||
argument will be used.
|
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
|
If no ids are provided they will be generated automatically from
|
||||||
the argvalues.
|
the argvalues.
|
||||||
|
|
||||||
|
@ -977,8 +997,18 @@ class Metafunc:
|
||||||
|
|
||||||
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
|
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)
|
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(
|
scopenum = scope2index(
|
||||||
scope, descr="parametrize() call in {}".format(self.function.__name__)
|
scope, descr="parametrize() call in {}".format(self.function.__name__)
|
||||||
)
|
)
|
||||||
|
@ -1013,27 +1043,48 @@ class Metafunc:
|
||||||
:rtype: List[str]
|
:rtype: List[str]
|
||||||
:return: the list of ids for each argname given
|
:return: the list of ids for each argname given
|
||||||
"""
|
"""
|
||||||
from _pytest._io.saferepr import saferepr
|
|
||||||
|
|
||||||
idfn = None
|
idfn = None
|
||||||
if callable(ids):
|
if callable(ids):
|
||||||
idfn = ids
|
idfn = ids
|
||||||
ids = None
|
ids = None
|
||||||
if ids:
|
if ids:
|
||||||
func_name = self.function.__name__
|
func_name = self.function.__name__
|
||||||
if len(ids) != len(parameters):
|
ids = self._validate_ids(ids, parameters, func_name)
|
||||||
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 = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
|
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
|
||||||
return ids
|
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):
|
def _resolve_arg_value_types(self, argnames, indirect):
|
||||||
"""Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg"
|
"""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.
|
to the function, based on the ``indirect`` parameter of the parametrized() call.
|
||||||
|
|
|
@ -9,6 +9,7 @@ from hypothesis import strategies
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest import fixtures
|
from _pytest import fixtures
|
||||||
from _pytest import python
|
from _pytest import python
|
||||||
|
from _pytest.outcomes import fail
|
||||||
from _pytest.python import _idval
|
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]))
|
||||||
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 test_parametrize_bad_scope(self, testdir):
|
||||||
def func(x):
|
def func(x):
|
||||||
pass
|
pass
|
||||||
|
@ -168,6 +202,26 @@ class TestMetafunc:
|
||||||
("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"]
|
("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):
|
def test_parametrize_empty_list(self):
|
||||||
"""#510"""
|
"""#510"""
|
||||||
|
|
||||||
|
@ -527,9 +581,22 @@ class TestMetafunc:
|
||||||
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
|
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
|
||||||
def test(arg):
|
def test(arg):
|
||||||
assert 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):
|
def test_idmaker_with_ids(self):
|
||||||
from _pytest.python import idmaker
|
from _pytest.python import idmaker
|
||||||
|
@ -1179,12 +1246,12 @@ class TestMetafuncFunctional:
|
||||||
result.stdout.fnmatch_lines(["* 1 skipped *"])
|
result.stdout.fnmatch_lines(["* 1 skipped *"])
|
||||||
|
|
||||||
def test_parametrized_ids_invalid_type(self, testdir):
|
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(
|
testdir.makepyfile(
|
||||||
"""
|
"""
|
||||||
import pytest
|
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):
|
def test_ids_numbers(x,expected):
|
||||||
assert x * 2 == expected
|
assert x * 2 == expected
|
||||||
"""
|
"""
|
||||||
|
@ -1192,7 +1259,8 @@ class TestMetafuncFunctional:
|
||||||
result = testdir.runpytest()
|
result = testdir.runpytest()
|
||||||
result.stdout.fnmatch_lines(
|
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 = testdir.runpytest()
|
||||||
result.assert_outcomes(passed=1)
|
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