Skip to content

Commit

Permalink
parametrized: ids: support generator/iterator
Browse files Browse the repository at this point in the history
Fixes #759

- Adjust test_parametrized_ids_invalid_type, create list to convert tuples
  Ref: #1857 (comment)

- Changelog for int to str conversion
  Ref: #1857 (comment)
  • Loading branch information
blueyed committed Nov 19, 2019
1 parent 1d368e0 commit 93fd61b
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 26 deletions.
1 change: 1 addition & 0 deletions changelog/1857.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings.
1 change: 1 addition & 0 deletions changelog/759.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``pytest.mark.parametrize`` supports iterators and generators for ``ids``.
23 changes: 21 additions & 2 deletions src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
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
Expand Down Expand Up @@ -144,7 +146,13 @@ class Mark:
#: keyword arguments of the mark decorator
kwargs = attr.ib() # Dict[str, object]

def combined_with(self, other):
_param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False)
_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
Expand All @@ -153,8 +161,19 @@ def combined_with(self, other):
combines by appending args and merging the mappings
"""
assert self.name == other.name

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


Expand Down
88 changes: 68 additions & 20 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from functools import partial
from textwrap import dedent
from typing import List
from typing import Optional
from typing import Tuple

import py
Expand All @@ -36,6 +37,7 @@
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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -915,7 +917,16 @@ def funcargnames(self):
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
Expand All @@ -938,13 +949,21 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
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.
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.
Expand Down Expand Up @@ -978,8 +997,16 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)

arg_values_types = self._resolve_arg_value_types(argnames, indirect)

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)

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__)
)
Expand Down Expand Up @@ -1014,26 +1041,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
: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})"
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)),
msg.format(func_name, saferepr(id_value), type(id_value), idx),
pytrace=False,
)
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
return ids
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"
Expand Down
112 changes: 108 additions & 4 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from _pytest import fixtures
from _pytest import python
from _pytest.outcomes import fail


class TestMetafunc:
Expand Down Expand Up @@ -61,6 +62,39 @@ def func(x, y):
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
Expand Down Expand Up @@ -167,6 +201,26 @@ def func(x, y):
("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"""

Expand Down Expand Up @@ -534,9 +588,22 @@ def ids(d):
@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
Expand Down Expand Up @@ -1186,20 +1253,21 @@ def test_temp(temp):
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
"""
)
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"
]
)

Expand Down Expand Up @@ -1780,3 +1848,39 @@ def test_foo(a):
)
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 *",
]
)

0 comments on commit 93fd61b

Please sign in to comment.