From e6c0d1fff36a2314432a2cf60b3fe4214d793bfa Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 4 Nov 2025 23:36:52 +0200 Subject: [PATCH 1/4] testing: remove deprecated `mktemp` fallback in py.path.local tests It's deprecated in typeshed, the fallback is probably not needed these days. --- testing/_py/test_local.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/testing/_py/test_local.py b/testing/_py/test_local.py index 592058a54a5..6b7d756a45c 100644 --- a/testing/_py/test_local.py +++ b/testing/_py/test_local.py @@ -734,12 +734,8 @@ def test_dump(self, tmpdir, bin): def test_setmtime(self): import tempfile - try: - fd, name = tempfile.mkstemp() - os.close(fd) - except AttributeError: - name = tempfile.mktemp() - open(name, "w").close() + fd, name = tempfile.mkstemp() + os.close(fd) try: # Do not use _pytest.timing here, as we do not want time mocking to affect this test. mtime = int(time.time()) - 100 From 213ae308051d8c0bbdc1c394bbb166c067122e1d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 4 Nov 2025 23:27:30 +0200 Subject: [PATCH 2/4] Enable mypy `@deprecated` decorator warnings Currently it's not enabled by default. See: https://mypy.readthedocs.io/en/stable/changelog.html#support-for-deprecated-decorator-pep-702 --- pyproject.toml | 1 + src/_pytest/logging.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f57d7e8e85b..1f5835254da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -532,6 +532,7 @@ warn_unreachable = true warn_unused_configs = true no_implicit_reexport = true warn_unused_ignores = true +enable_error_code = [ "deprecated" ] [tool.pyright] include = [ diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index e4fed579d21..6f34c1b93fd 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -513,7 +513,7 @@ def _force_enable_logging( if isinstance(level, str): # Try to translate the level string to an int for `logging.disable()` - level = logging.getLevelName(level) + level = logging.getLevelName(level) # type: ignore[deprecated] if not isinstance(level, int): # The level provided was not valid, so just un-disable all logging. From 7d9375781c694577219bec435ad3c37c52e00d03 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 5 Nov 2025 17:01:40 +0200 Subject: [PATCH 3/4] compat: add compat for `@warnings.deprecated` We intend to start using it. --- src/_pytest/compat.py | 15 +++++++++++++++ testing/test_compat.py | 20 +++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 2f5a4c863f9..316d2fbd42b 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -15,6 +15,7 @@ from typing import Any from typing import Final from typing import NoReturn +from typing import TYPE_CHECKING import py @@ -311,3 +312,17 @@ def running_on_ci() -> bool: # Only enable CI mode if one of these env variables is defined and non-empty. env_vars = ["CI", "BUILD_NUMBER"] return any(os.environ.get(var) for var in env_vars) + + +if sys.version_info >= (3, 13): + from warnings import deprecated as deprecated +else: + if TYPE_CHECKING: + from typing_extensions import deprecated as deprecated + else: + + def deprecated(msg, /, *, category=None, stacklevel=1): + def decorator(func): + return func + + return decorator diff --git a/testing/test_compat.py b/testing/test_compat.py index fa9e259647f..2c86f06c9dd 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -5,9 +5,11 @@ from functools import cached_property from functools import partial from functools import wraps -from typing import TYPE_CHECKING +from typing import Literal +import warnings from _pytest.compat import assert_never +from _pytest.compat import deprecated from _pytest.compat import get_real_func from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass @@ -15,10 +17,6 @@ import pytest -if TYPE_CHECKING: - from typing import Literal - - def test_real_func_loop_limit() -> None: class Evil: def __init__(self): @@ -192,3 +190,15 @@ def test_assert_never_literal() -> None: pass else: assert_never(x) + + +def test_deprecated() -> None: + # This test is mostly for coverage. + + @deprecated("This is deprecated!") + def old_way() -> str: + return "human intelligence" + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert old_way() == "human intelligence" # type: ignore[deprecated] From dd47a89d6eb03e7d18e2fea4fd0a198b1ec5f4a5 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 4 Nov 2025 21:21:30 +0200 Subject: [PATCH 4/4] Deprecate passing iterator argvalues to parametrize Fix #13409. --- changelog/13409.deprecation.rst | 8 ++++ doc/en/deprecations.rst | 61 ++++++++++++++++++++++++++ src/_pytest/deprecated.py | 8 ++++ src/_pytest/mark/structures.py | 31 ++++++++++++- src/_pytest/python.py | 6 +++ testing/python/metafunc.py | 77 +++++++++++++++++++++++++++++++++ testing/typing_checks.py | 7 +++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 changelog/13409.deprecation.rst diff --git a/changelog/13409.deprecation.rst b/changelog/13409.deprecation.rst new file mode 100644 index 00000000000..d4fcf2c8a5a --- /dev/null +++ b/changelog/13409.deprecation.rst @@ -0,0 +1,8 @@ +Using non-:class:`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects) for the ``argvalues`` parameter in :ref:`@pytest.mark.parametrize ` and :meth:`metafunc.parametrize ` is now deprecated. + +These iterables get exhausted after the first iteration, +leading to tests getting unexpectedly skipped in cases such as running :func:`pytest.main()` multiple times, +using class-level parametrize decorators, +or collecting tests multiple times. + +See :ref:`parametrize-iterators` for details and suggestions. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 65a05823517..f2a665a6267 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,67 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _parametrize-iterators: + +Non-Collection iterables in ``@pytest.mark.parametrize`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.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 ` +and :meth:`metafunc.parametrize ` is deprecated. + +These iterables get exhausted after the first iteration, leading to tests getting unexpectedly skipped in cases such as: + +* Running :func:`pytest.main()` multiple times in the same process +* Using class-level parametrize decorators where the same mark is applied to multiple test methods +* Collecting tests multiple times + +Example of problematic code: + +.. code-block:: python + + import pytest + + + def data_generator(): + yield 1 + yield 2 + + + @pytest.mark.parametrize("n", data_generator()) + class Test: + def test_1(self, n): + pass + + # test_2 will be skipped because data_generator() is exhausted. + def test_2(self, n): + pass + +You can fix it by convert generators and iterators to lists or tuples: + +.. code-block:: python + + import pytest + + + def data_generator(): + yield 1 + yield 2 + + + @pytest.mark.parametrize("n", list(data_generator())) + class Test: + def test_1(self, n): + pass + + def test_2(self, n): + pass + +Note that :class:`range` objects are ``Collection`` and are not affected by this deprecation. + + .. _monkeypatch-fixup-namespace-packages: ``monkeypatch.syspath_prepend`` with legacy namespace packages diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index cb5d2e93e93..a8be4881433 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -75,6 +75,14 @@ "See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages" ) +PARAMETRIZE_NON_COLLECTION_ITERABLE = UnformattedWarning( + PytestRemovedIn10Warning, + "Passing a non-Collection iterable to parametrize is deprecated.\n" + "Test: {nodeid}, argvalues type: {type_name}\n" + "Please convert to a list or tuple.\n" + "See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators", +) + # You want to make some `__init__` or function "private". # # def my_private_function(some, args): diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 16bb6d81119..9f2f6279158 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -21,11 +21,13 @@ import warnings from .._code import getfslineno +from ..compat import deprecated from ..compat import NOTSET from ..compat import NotSetType from _pytest.config import Config from _pytest.deprecated import check_ispytest from _pytest.deprecated import MARKED_FIXTURE +from _pytest.deprecated import PARAMETRIZE_NON_COLLECTION_ITERABLE from _pytest.outcomes import fail from _pytest.raises import AbstractRaises from _pytest.scope import _ScopeName @@ -193,6 +195,15 @@ def _for_parametrize( config: Config, nodeid: str, ) -> tuple[Sequence[str], list[ParameterSet]]: + if not isinstance(argvalues, Collection): + warnings.warn( + PARAMETRIZE_NON_COLLECTION_ITERABLE.format( + nodeid=nodeid, + type_name=type(argvalues).__name__, + ), + stacklevel=3, + ) + argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) del argvalues @@ -508,7 +519,25 @@ def __call__( ) -> MarkDecorator: ... class _ParametrizeMarkDecorator(MarkDecorator): - def __call__( # type: ignore[override] + @overload # type: ignore[override,no-overload-impl] + def __call__( + self, + argnames: str | Sequence[str], + argvalues: Collection[ParameterSet | Sequence[object] | object], + *, + indirect: bool | Sequence[str] = ..., + ids: Iterable[None | str | float | int | bool] + | Callable[[Any], object | None] + | None = ..., + scope: _ScopeName | None = ..., + ) -> MarkDecorator: ... + + @overload + @deprecated( + "Passing a non-Collection iterable to the 'argvalues' parameter of @pytest.mark.parametrize is deprecated. " + "Convert argvalues to a list or tuple.", + ) + def __call__( self, argnames: str | Sequence[str], argvalues: Iterable[ParameterSet | Sequence[object] | object], diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e63751877a4..257dd78f462 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1247,6 +1247,12 @@ def parametrize( N-tuples, where each tuple-element specifies a value for its respective argname. + .. versionchanged:: 9.1 + + Passing a non-:class:`~collections.abc.Collection` iterable + (such as a generator or iterator) is deprecated. See + :ref:`parametrize-iterators` for details. + :param indirect: A list of arguments' names (subset of argnames) or a boolean. If True the list contains all names from the argnames. Each diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 20ccacf4b73..82a5509c49a 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1123,6 +1123,24 @@ def test_3(self, arg, arg2): """ ) + def test_parametrize_iterator_deprecation(self) -> None: + """Test that using iterators for argvalues raises a deprecation warning.""" + + def func(x: int) -> None: + raise NotImplementedError() + + def data_generator() -> Iterator[int]: + yield 1 + yield 2 + + metafunc = self.Metafunc(func) + + with pytest.warns( + pytest.PytestRemovedIn10Warning, + match=r"Passing a non-Collection iterable to parametrize is deprecated", + ): + metafunc.parametrize("x", data_generator()) + class TestMetafuncFunctional: def test_attributes(self, pytester: Pytester) -> None: @@ -1682,6 +1700,65 @@ def test_3(self, fixture): ] ) + def test_parametrize_generator_multiple_runs(self, pytester: Pytester) -> None: + """Test that generators in parametrize work with multiple pytest.main() (deprecated).""" + testfile = pytester.makepyfile( + """ + import pytest + + def data_generator(): + yield 1 + yield 2 + + @pytest.mark.parametrize("bar", data_generator()) + def test_foo(bar): + pass + + if __name__ == '__main__': + args = ["-q", "--collect-only"] + pytest.main(args) # First run - should work with warning + pytest.main(args) # Second run - should also work with warning + """ + ) + result = pytester.run(sys.executable, "-Wdefault", testfile) + # Should see the deprecation warnings. + result.stdout.fnmatch_lines( + [ + "*PytestRemovedIn10Warning: Passing a non-Collection iterable*", + "*PytestRemovedIn10Warning: Passing a non-Collection iterable*", + ] + ) + + def test_parametrize_iterator_class_multiple_tests( + self, pytester: Pytester + ) -> None: + """Test that iterators in parametrize on a class get exhausted (deprecated).""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("n", iter(range(2))) + class Test: + def test_1(self, n): + pass + + def test_2(self, n): + pass + """ + ) + result = pytester.runpytest("-v", "-Wdefault") + # Iterator gets exhausted after first test, second test gets no parameters. + # This is deprecated. + result.assert_outcomes(passed=2, skipped=1) + result.stdout.fnmatch_lines( + [ + "*test_parametrize_iterator_class_multiple_tests.py::Test::test_1[[]0] PASSED*", + "*test_parametrize_iterator_class_multiple_tests.py::Test::test_1[[]1] PASSED*", + "*test_parametrize_iterator_class_multiple_tests.py::Test::test_2[[]NOTSET] SKIPPED*", + "*PytestRemovedIn10Warning: Passing a non-Collection iterable*", + ] + ) + class TestMetafuncFunctionalAuto: """Tests related to automatically find out the correct scope for diff --git a/testing/typing_checks.py b/testing/typing_checks.py index 3ee2dfb3019..ff1c0e60cd9 100644 --- a/testing/typing_checks.py +++ b/testing/typing_checks.py @@ -58,3 +58,10 @@ def check_raises_is_a_context_manager(val: bool) -> None: def check_testreport_attributes(report: TestReport) -> None: assert_type(report.when, Literal["setup", "call", "teardown"]) assert_type(report.location, tuple[str, int | None, str]) + + +# Test @pytest.mark.parametrize iterator argvalues deprecation. +# Will be complain about unused type ignore if doesn't work. +@pytest.mark.parametrize("x", iter(range(10))) # type: ignore[deprecated] +def test_it(x: int) -> None: + pass