From 526288bcb19aac64895fad59956ecf8058706118 Mon Sep 17 00:00:00 2001 From: Omri Golan Date: Fri, 26 Sep 2025 20:57:56 +0300 Subject: [PATCH 1/2] python: add `strict_parametrization_ids` option Fix #13737. --- AUTHORS | 1 + changelog/13737.feature.rst | 4 ++ doc/en/reference/reference.rst | 38 ++++++++++++++ src/_pytest/python.py | 46 ++++++++++++++++- testing/test_collection.py | 92 ++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 changelog/13737.feature.rst diff --git a/AUTHORS b/AUTHORS index 9539e8dc4f4..b28f49776d6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -346,6 +346,7 @@ Oliver Bestwalter Olivier Grisel Omar Kohl Omer Hadari +Omri Golan Ondřej Súkup Oscar Benjamin Parth Patel diff --git a/changelog/13737.feature.rst b/changelog/13737.feature.rst new file mode 100644 index 00000000000..e7fe7fd3ab7 --- /dev/null +++ b/changelog/13737.feature.rst @@ -0,0 +1,4 @@ +Added the :confval:`strict_parametrization_ids` configuration option. + +When set, pytest emits an error if it detects non-unique parameter set IDs, +rather than automatically making the IDs unique by adding `0`, `1`, ... to them. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index a2d275fdcfe..3dfa11901ea 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -2082,6 +2082,44 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] xfail_strict = True +.. confval:: strict_parametrization_ids + + If set, pytest emits an error if it detects non-unique parameter set IDs. + + If not set (the default), pytest automatically handles this by adding `0`, `1`, ... to duplicate IDs, + making them unique. + + .. code-block:: ini + + [pytest] + strict_parametrization_ids = True + + For example, + + .. code-block:: python + + import pytest + + + @pytest.mark.parametrize("letter", ["a", "a"]) + def test_letter_is_ascii(letter): + assert letter.isascii() + + will emit an error because both cases (parameter sets) have the same auto-generated ID "a". + + To fix the error, if you decide to keep the duplicates, explicitly assign unique IDs: + + .. code-block:: python + + import pytest + + + @pytest.mark.parametrize("letter", ["a", "a"], ids=["a0", "a1"]) + def test_letter_is_ascii(letter): + assert letter.isascii() + + See :func:`parametrize ` and :func:`pytest.param` for other ways to set IDs. + .. _`command-line-flags`: diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3f9da026799..4dbf1cc8775 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -21,6 +21,7 @@ import os from pathlib import Path import re +import textwrap import types from typing import Any from typing import final @@ -107,6 +108,12 @@ def pytest_addoption(parser: Parser) -> None: help="Disable string escape non-ASCII characters, might cause unwanted " "side effects(use at your own risk)", ) + parser.addini( + "strict_parametrization_ids", + type="bool", + default=False, + help="Emit an error if non-unique parameter set IDs are detected", + ) def pytest_generate_tests(metafunc: Metafunc) -> None: @@ -878,8 +885,8 @@ class IdMaker: # Optionally, explicit IDs for ParameterSets by index. ids: Sequence[object | None] | None # Optionally, the pytest config. - # Used for controlling ASCII escaping, and for calling the - # :hook:`pytest_make_parametrize_id` hook. + # Used for controlling ASCII escaping, determining parametrization ID + # strictness, and for calling the :hook:`pytest_make_parametrize_id` hook. config: Config | None # Optionally, the ID of the node being parametrized. # Used only for clearer error messages. @@ -892,6 +899,9 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: """Make a unique identifier for each ParameterSet, that may be used to identify the parametrization in a node ID. + If strict_parametrization_ids is enabled, and duplicates are detected, + raises CollectError. Otherwise makes the IDs unique as follows: + Format is -...-[counter], where prm_x_token is - user-provided id, if given - else an id derived from the value, applicable for certain types @@ -904,6 +914,33 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: if len(resolved_ids) != len(set(resolved_ids)): # Record the number of occurrences of each ID. id_counts = Counter(resolved_ids) + + if self._strict_parametrization_ids_enabled(): + parameters = ", ".join(self.argnames) + parametersets = ", ".join( + [saferepr(list(param.values)) for param in self.parametersets] + ) + ids = ", ".join( + id if id is not HIDDEN_PARAM else "" for id in resolved_ids + ) + duplicates = ", ".join( + id if id is not HIDDEN_PARAM else "" + for id, count in id_counts.items() + if count > 1 + ) + msg = textwrap.dedent(f""" + Duplicate parametrization IDs detected, but strict_parametrization_ids is set. + + Test name: {self.nodeid} + Parameters: {parameters} + Parameter sets: {parametersets} + IDs: {ids} + Duplicates: {duplicates} + + You can fix this problem using `@pytest.mark.parametrize(..., ids=...)` or `pytest.param(..., id=...)`. + """).strip() # noqa: E501 + raise nodes.Collector.CollectError(msg) + # Map the ID to its next suffix. id_suffixes: dict[str, int] = defaultdict(int) # Suffix non-unique IDs to make them unique. @@ -925,6 +962,11 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: ) return resolved_ids + def _strict_parametrization_ids_enabled(self) -> bool: + if self.config: + return bool(self.config.getini("strict_parametrization_ids")) + return False + def _resolve_ids(self) -> Iterable[str | _HiddenParam]: """Resolve IDs for all ParameterSets (may contain duplicates).""" for idx, parameterset in enumerate(self.parametersets): diff --git a/testing/test_collection.py b/testing/test_collection.py index 40568a9bdf4..153811dea3e 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations +from collections.abc import Sequence import os from pathlib import Path from pathlib import PurePath @@ -2702,3 +2703,94 @@ def test_1(): pass ], consecutive=True, ) + + +@pytest.mark.parametrize( + ["x_y", "expected_duplicates"], + [ + ( + [(1, 1), (1, 1)], + ["1-1"], + ), + ( + [(1, 1), (1, 2), (1, 1)], + ["1-1"], + ), + ( + [(1, 1), (2, 2), (1, 1)], + ["1-1"], + ), + ( + [(1, 1), (2, 2), (1, 2), (2, 1), (1, 1), (2, 1)], + ["1-1", "2-1"], + ), + ], +) +def test_strict_parametrization_ids( + pytester: Pytester, + x_y: Sequence[tuple[int, int]], + expected_duplicates: Sequence[str], +) -> None: + pytester.makeini( + """ + [pytest] + strict_parametrization_ids = true + """ + ) + pytester.makepyfile( + f""" + import pytest + + @pytest.mark.parametrize(["x", "y"], {x_y}) + def test1(x, y): + pass + """ + ) + + result = pytester.runpytest() + + assert result.ret == ExitCode.INTERRUPTED + expected_parametersets = ", ".join(str(list(p)) for p in x_y) + expected_ids = ", ".join(f"{x}-{y}" for x, y in x_y) + result.stdout.fnmatch_lines( + [ + "Duplicate parametrization IDs detected*", + "", + "Test name: *::test1", + "Parameters: x, y", + f"Parameter sets: {expected_parametersets}", + f"IDs: {expected_ids}", + f"Duplicates: {', '.join(expected_duplicates)}", + "", + "You can fix this problem using *", + ] + ) + + +def test_strict_parametrization_ids_with_hidden_param(pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + strict_parametrization_ids = true + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize(["x"], ["a", pytest.param("a", id=pytest.HIDDEN_PARAM), "a"]) + def test1(x): + pass + """ + ) + + result = pytester.runpytest() + + assert result.ret == ExitCode.INTERRUPTED + result.stdout.fnmatch_lines( + [ + "Duplicate parametrization IDs detected*", + "IDs: a, , a", + "Duplicates: a", + ] + ) From 5df54c6de87497fe8ff3a8b5e8c7b3a45411ee48 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 18 Oct 2025 19:47:10 +0300 Subject: [PATCH 2/2] testing: enable strict_parametrization_ids and fix problems --- pyproject.toml | 1 + testing/_py/test_local.py | 2 +- testing/test_doctest.py | 9 ++++++++- testing/test_mark_expression.py | 1 - 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 091676409ed..964c4f449dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -379,6 +379,7 @@ norecursedirs = [ "dist", ] xfail_strict = true +strict_parametrization_ids = true filterwarnings = [ "error", "default:Using or importing the ABCs:DeprecationWarning:unittest2.*", diff --git a/testing/_py/test_local.py b/testing/_py/test_local.py index 673ce9b3de6..e7301c273e0 100644 --- a/testing/_py/test_local.py +++ b/testing/_py/test_local.py @@ -209,7 +209,7 @@ def test_visit_norecurse(self, path1): @pytest.mark.parametrize( "fil", - ["*dir", "*dir", pytest.mark.skip("sys.version_info < (3,6)")(b"*dir")], + ["*dir", pytest.mark.skip("sys.version_info < (3,6)")(b"*dir")], ) def test_visit_filterfunc_is_string(self, path1, fil): lst = [] diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 415d3a24faa..e2ca1119e92 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1596,7 +1596,14 @@ def __getattr__(self, _): @pytest.mark.parametrize( # pragma: no branch (lambdas are not called) - "stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True] + "stop", + [ + None, + pytest.param(_is_mocked, id="is_mocked"), + pytest.param(lambda f: None, id="lambda_none"), + pytest.param(lambda f: False, id="lambda_false"), + pytest.param(lambda f: True, id="lambda_true"), + ], ) def test_warning_on_unwrap_of_broken_object( stop: Callable[[object], object] | None, diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index 1b93130349b..1e3c769347c 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -20,7 +20,6 @@ def test_empty_is_false() -> None: @pytest.mark.parametrize( ("expr", "expected"), ( - ("true", True), ("true", True), ("false", False), ("not true", False),