Skip to content

Commit 7846fdd

Browse files
committed
Deprecate passing iterator argvalues to parametrize
Fix #13409.
1 parent 213ae30 commit 7846fdd

File tree

8 files changed

+212
-1
lines changed

8 files changed

+212
-1
lines changed

changelog/13409.deprecation.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Using non-:class:`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects) for the ``argvalues`` parameter in :ref:`@pytest.mark.parametrize <pytest.mark.parametrize ref>` and :meth:`metafunc.parametrize <pytest.Metafunc.parametrize>` is now deprecated.
2+
3+
These iterables get exhausted after the first iteration,
4+
leading to tests getting unexpectedly skipped in cases such as running :func:`pytest.main()` multiple times,
5+
using class-level parametrize decorators,
6+
or collecting tests multiple times.
7+
8+
See :ref:`parametrize-iterators` for details and suggestions.

doc/en/deprecations.rst

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,67 @@ Below is a complete list of all pytest features which are considered deprecated.
1515
:class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
1616

1717

18+
.. _parametrize-iterators:
19+
20+
Non-Collection iterables in ``@pytest.mark.parametrize``
21+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
22+
23+
.. deprecated:: 9.1
24+
25+
Using non-:class:`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects)
26+
for the ``argvalues`` parameter in :ref:`@pytest.mark.parametrize <pytest.mark.parametrize ref>`
27+
and :meth:`metafunc.parametrize <pytest.Metafunc.parametrize>` is deprecated.
28+
29+
These iterables get exhausted after the first iteration, leading to tests getting unexpectedly skipped in cases such as:
30+
31+
* Running :func:`pytest.main()` multiple times in the same process
32+
* Using class-level parametrize decorators where the same mark is applied to multiple test methods
33+
* Collecting tests multiple times
34+
35+
Example of problematic code:
36+
37+
.. code-block:: python
38+
39+
import pytest
40+
41+
42+
def data_generator():
43+
yield 1
44+
yield 2
45+
46+
47+
@pytest.mark.parametrize("n", data_generator())
48+
class Test:
49+
def test_1(self, n):
50+
pass
51+
52+
# test_2 will be skipped because data_generator() is exhausted.
53+
def test_2(self, n):
54+
pass
55+
56+
You can fix it by convert generators and iterators to lists or tuples:
57+
58+
.. code-block:: python
59+
60+
import pytest
61+
62+
63+
def data_generator():
64+
yield 1
65+
yield 2
66+
67+
68+
@pytest.mark.parametrize("n", list(data_generator()))
69+
class Test:
70+
def test_1(self, n):
71+
pass
72+
73+
def test_2(self, n):
74+
pass
75+
76+
Note that :class:`range` objects are ``Collection`` and are not affected by this deprecation.
77+
78+
1879
.. _monkeypatch-fixup-namespace-packages:
1980

2081
``monkeypatch.syspath_prepend`` with legacy namespace packages

src/_pytest/compat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any
1616
from typing import Final
1717
from typing import NoReturn
18+
from typing import TYPE_CHECKING
1819

1920
import py
2021

@@ -311,3 +312,17 @@ def running_on_ci() -> bool:
311312
# Only enable CI mode if one of these env variables is defined and non-empty.
312313
env_vars = ["CI", "BUILD_NUMBER"]
313314
return any(os.environ.get(var) for var in env_vars)
315+
316+
317+
if sys.version_info >= (3, 13):
318+
from warnings import deprecated as deprecated
319+
else:
320+
if TYPE_CHECKING:
321+
from typing_extensions import deprecated as deprecated
322+
else:
323+
324+
def deprecated(msg, /, *, category=None, stacklevel=1):
325+
def decorator(func):
326+
return func
327+
328+
return decorator

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@
7575
"See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages"
7676
)
7777

78+
PARAMETRIZE_NON_COLLECTION_ITERABLE = UnformattedWarning(
79+
PytestRemovedIn10Warning,
80+
"Passing a non-Collection iterable to parametrize is deprecated.\n"
81+
"Test: {nodeid}, argvalues type: {type_name}\n"
82+
"Please convert to a list or tuple.\n"
83+
"See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators",
84+
)
85+
7886
# You want to make some `__init__` or function "private".
7987
#
8088
# def my_private_function(some, args):

src/_pytest/mark/structures.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import warnings
2222

2323
from .._code import getfslineno
24+
from ..compat import deprecated
2425
from ..compat import NOTSET
2526
from ..compat import NotSetType
2627
from _pytest.config import Config
2728
from _pytest.deprecated import check_ispytest
2829
from _pytest.deprecated import MARKED_FIXTURE
30+
from _pytest.deprecated import PARAMETRIZE_NON_COLLECTION_ITERABLE
2931
from _pytest.outcomes import fail
3032
from _pytest.raises import AbstractRaises
3133
from _pytest.scope import _ScopeName
@@ -193,6 +195,15 @@ def _for_parametrize(
193195
config: Config,
194196
nodeid: str,
195197
) -> tuple[Sequence[str], list[ParameterSet]]:
198+
if not isinstance(argvalues, Collection):
199+
warnings.warn(
200+
PARAMETRIZE_NON_COLLECTION_ITERABLE.format(
201+
nodeid=nodeid,
202+
type_name=type(argvalues).__name__,
203+
),
204+
stacklevel=3,
205+
)
206+
196207
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
197208
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
198209
del argvalues
@@ -508,7 +519,25 @@ def __call__(
508519
) -> MarkDecorator: ...
509520

510521
class _ParametrizeMarkDecorator(MarkDecorator):
511-
def __call__( # type: ignore[override]
522+
@overload # type: ignore[override,no-overload-impl]
523+
def __call__(
524+
self,
525+
argnames: str | Sequence[str],
526+
argvalues: Collection[ParameterSet | Sequence[object] | object],
527+
*,
528+
indirect: bool | Sequence[str] = ...,
529+
ids: Iterable[None | str | float | int | bool]
530+
| Callable[[Any], object | None]
531+
| None = ...,
532+
scope: _ScopeName | None = ...,
533+
) -> MarkDecorator: ...
534+
535+
@overload
536+
@deprecated(
537+
"Passing a non-Collection iterable to the 'argvalues' parameter of @pytest.mark.parametrize is deprecated. "
538+
"Convert argvalues to a list or tuple.",
539+
)
540+
def __call__(
512541
self,
513542
argnames: str | Sequence[str],
514543
argvalues: Iterable[ParameterSet | Sequence[object] | object],

src/_pytest/python.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,12 @@ def parametrize(
12471247
N-tuples, where each tuple-element specifies a value for its
12481248
respective argname.
12491249
1250+
.. versionchanged:: 9.1
1251+
1252+
Passing a non-:class:`~collections.abc.Collection` iterable
1253+
(such as a generator or iterator) is deprecated. See
1254+
:ref:`parametrize-iterators` for details.
1255+
12501256
:param indirect:
12511257
A list of arguments' names (subset of argnames) or a boolean.
12521258
If True the list contains all names from the argnames. Each

testing/python/metafunc.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,24 @@ def test_3(self, arg, arg2):
11231123
"""
11241124
)
11251125

1126+
def test_parametrize_iterator_deprecation(self) -> None:
1127+
"""Test that using iterators for argvalues raises a deprecation warning."""
1128+
1129+
def func(x: int) -> None:
1130+
raise NotImplementedError()
1131+
1132+
def data_generator() -> Iterator[int]:
1133+
yield 1
1134+
yield 2
1135+
1136+
metafunc = self.Metafunc(func)
1137+
1138+
with pytest.warns(
1139+
pytest.PytestRemovedIn10Warning,
1140+
match=r"Passing a non-Collection iterable to parametrize is deprecated",
1141+
):
1142+
metafunc.parametrize("x", data_generator())
1143+
11261144

11271145
class TestMetafuncFunctional:
11281146
def test_attributes(self, pytester: Pytester) -> None:
@@ -1682,6 +1700,65 @@ def test_3(self, fixture):
16821700
]
16831701
)
16841702

1703+
def test_parametrize_generator_multiple_runs(self, pytester: Pytester) -> None:
1704+
"""Test that generators in parametrize work with multiple pytest.main() (deprecated)."""
1705+
testfile = pytester.makepyfile(
1706+
"""
1707+
import pytest
1708+
1709+
def data_generator():
1710+
yield 1
1711+
yield 2
1712+
1713+
@pytest.mark.parametrize("bar", data_generator())
1714+
def test_foo(bar):
1715+
pass
1716+
1717+
if __name__ == '__main__':
1718+
args = ["-q", "--collect-only"]
1719+
pytest.main(args) # First run - should work with warning
1720+
pytest.main(args) # Second run - should also work with warning
1721+
"""
1722+
)
1723+
result = pytester.run(sys.executable, "-Wdefault", testfile)
1724+
# Should see the deprecation warnings.
1725+
result.stdout.fnmatch_lines(
1726+
[
1727+
"*PytestRemovedIn10Warning: Passing a non-Collection iterable*",
1728+
"*PytestRemovedIn10Warning: Passing a non-Collection iterable*",
1729+
]
1730+
)
1731+
1732+
def test_parametrize_iterator_class_multiple_tests(
1733+
self, pytester: Pytester
1734+
) -> None:
1735+
"""Test that iterators in parametrize on a class get exhausted (deprecated)."""
1736+
pytester.makepyfile(
1737+
"""
1738+
import pytest
1739+
1740+
@pytest.mark.parametrize("n", iter(range(2)))
1741+
class Test:
1742+
def test_1(self, n):
1743+
pass
1744+
1745+
def test_2(self, n):
1746+
pass
1747+
"""
1748+
)
1749+
result = pytester.runpytest("-v", "-Wdefault")
1750+
# Iterator gets exhausted after first test, second test gets no parameters.
1751+
# This is deprecated.
1752+
result.assert_outcomes(passed=2, skipped=1)
1753+
result.stdout.fnmatch_lines(
1754+
[
1755+
"*test_parametrize_iterator_class_multiple_tests.py::Test::test_1[[]0] PASSED*",
1756+
"*test_parametrize_iterator_class_multiple_tests.py::Test::test_1[[]1] PASSED*",
1757+
"*test_parametrize_iterator_class_multiple_tests.py::Test::test_2[[]NOTSET] SKIPPED*",
1758+
"*PytestRemovedIn10Warning: Passing a non-Collection iterable*",
1759+
]
1760+
)
1761+
16851762

16861763
class TestMetafuncFunctionalAuto:
16871764
"""Tests related to automatically find out the correct scope for

testing/typing_checks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,10 @@ def check_raises_is_a_context_manager(val: bool) -> None:
5858
def check_testreport_attributes(report: TestReport) -> None:
5959
assert_type(report.when, Literal["setup", "call", "teardown"])
6060
assert_type(report.location, tuple[str, int | None, str])
61+
62+
63+
# Test @pytest.mark.parametrize iterator argvalues deprecation.
64+
# Will be complain about unused type ignore if doesn't work.
65+
@pytest.mark.parametrize("x", iter(range(10))) # type: ignore[deprecated]
66+
def test_it(x: int) -> None:
67+
pass

0 commit comments

Comments
 (0)