Merge pull request #6174 from blueyed/ids-iter

parametrized: ids: support generator/iterator
This commit is contained in:
Daniel Hahler 2019-11-21 00:37:25 +01:00 committed by GitHub
commit ed012c808a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 206 additions and 27 deletions

View File

@ -0,0 +1 @@
``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings.

View File

@ -0,0 +1 @@
``pytest.mark.parametrize`` supports iterators and generators for ``ids``.

View File

@ -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,
) )

View File

@ -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.

View File

@ -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 *",
]
)