Skip to content

Commit 9734d85

Browse files
committed
feat: add cli, ini option skips pytest internal logic of id generation and raise error
1 parent 1d0c8a7 commit 9734d85

File tree

5 files changed

+207
-1
lines changed

5 files changed

+207
-1
lines changed

changelog/13737.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added the ``--require-unique-parametrization-ids`` command-line flag and :confval:`require_unique_parametrization_ids` configuration option.
2+
3+
When passed, pytest will emit an error if it detects non-unique parameter set IDs,
4+
rather than automatically making the IDs unique by adding `0`, `1`, ... to them.

doc/en/reference/reference.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,6 +2082,30 @@ passed multiple times. The expected format is ``name=value``. For example::
20822082
[pytest]
20832083
xfail_strict = True
20842084
2085+
.. confval:: require_unique_parametrization_ids
2086+
2087+
When passed, this flag causes pytest to raise an exception upon detection of non-unique parameter set IDs,
2088+
rather than attempting to generate them automatically.
2089+
2090+
Can be overridden by `--require-unique-parametrization-ids`.
2091+
2092+
.. code-block:: ini
2093+
2094+
[pytest]
2095+
require_unique_parametrization_ids = True
2096+
2097+
.. code-block:: python
2098+
2099+
import pytest
2100+
2101+
2102+
@pytest.mark.parametrize("x", [1, 2], ids=["a", "a"])
2103+
def test_example(x):
2104+
assert x in (1, 2)
2105+
2106+
will raise an exception due to the duplicate IDs "a".
2107+
when normal pytest behavior would be to handle this by generating unique IDs like "a-0", "a-1".
2108+
20852109

20862110
.. _`command-line-flags`:
20872111

@@ -2239,6 +2263,11 @@ All the command-line flags can be obtained by running ``pytest --help``::
22392263
--doctest-continue-on-failure
22402264
For a given doctest, continue to run after the first
22412265
failure
2266+
--require-unique-parametrization-ids
2267+
If pytest collects test ids with non-unique names, raise an
2268+
error rather than handling it.
2269+
Useful if you collect in one process,
2270+
and then execute tests in independent workers.
22422271

22432272
test session debugging and configuration:
22442273
-c, --config-file FILE

src/_pytest/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ def pytest_addoption(parser: Parser) -> None:
9090
action="store_true",
9191
help="(Deprecated) alias to --strict-markers",
9292
)
93+
group.addoption(
94+
"--require-unique-parametrization-ids",
95+
action="store_true",
96+
default=False,
97+
help="When passed, this flag causes pytest to raise an exception upon detection of non-unique parameter set IDs"
98+
" rather than attempting to generate them automatically.",
99+
)
100+
101+
parser.addini(
102+
"require_unique_parametrization_ids",
103+
type="bool",
104+
default=False,
105+
help="When passed, this flag causes pytest to raise an exception upon detection of non-unique parameter set IDs"
106+
" rather than attempting to generate them automatically.",
107+
)
93108

94109
group = parser.getgroup("pytest-warnings")
95110
group.addoption(

src/_pytest/python.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import os
2222
from pathlib import Path
2323
import re
24+
import textwrap
2425
import types
2526
from typing import Any
2627
from typing import final
@@ -902,6 +903,29 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
902903
resolved_ids = list(self._resolve_ids())
903904
# All IDs must be unique!
904905
if len(resolved_ids) != len(set(resolved_ids)):
906+
if self._require_unique_ids_enabled():
907+
duplicate_indexs = defaultdict(list)
908+
for i, val in enumerate(resolved_ids):
909+
duplicate_indexs[val].append(i)
910+
911+
# Keep only duplicates
912+
duplicates = {k: v for k, v in duplicate_indexs.items() if len(v) > 1}
913+
arugment_values = [
914+
saferepr(param.values) for param in self.parametersets
915+
]
916+
error_msg = f"""
917+
Duplicate parametrization IDs detected, but --require-unique-parametrization-ids is set.
918+
919+
Test name: {self.nodeid}
920+
Argument names: {self.argnames}
921+
Argument values: {arugment_values}
922+
Resolved IDs: {resolved_ids}
923+
Duplicates: {duplicates}
924+
925+
You can fix this problem using:
926+
the `ids` argument of `@pytest.mark.parametrize()` or the `id` argument of `pytest.param()`.
927+
"""
928+
raise nodes.Collector.CollectError(textwrap.dedent(error_msg))
905929
# Record the number of occurrences of each ID.
906930
id_counts = Counter(resolved_ids)
907931
# Map the ID to its next suffix.
@@ -925,6 +949,14 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
925949
)
926950
return resolved_ids
927951

952+
def _require_unique_ids_enabled(self) -> bool:
953+
if self.config:
954+
cli_value = self.config.getoption("require_unique_parametrization_ids")
955+
if cli_value:
956+
return bool(cli_value)
957+
return bool(self.config.getini("require_unique_parametrization_ids"))
958+
return False
959+
928960
def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
929961
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
930962
for idx, parameterset in enumerate(self.parametersets):

testing/test_collection.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# mypy: allow-untyped-defs
22
from __future__ import annotations
33

4+
from collections.abc import Sequence
45
import os
56
from pathlib import Path
67
from pathlib import PurePath
78
import pprint
9+
import re
810
import shutil
911
import sys
1012
import tempfile
@@ -20,6 +22,7 @@
2022
from _pytest.pathlib import symlink_or_skip
2123
from _pytest.pytester import HookRecorder
2224
from _pytest.pytester import Pytester
25+
from _pytest.pytester import RunResult
2326
import pytest
2427

2528

@@ -2497,7 +2500,7 @@ def test_1(): pass
24972500
" <Class TestIt>",
24982501
" <Function test_2>",
24992502
" <Function test_3>",
2500-
" <Module test_2.py>",
2503+
# " <Module test_2.py>",
25012504
" <Function test_1>",
25022505
" <Package top2>",
25032506
" <Module test_1.py>",
@@ -2702,3 +2705,126 @@ def test_1(): pass
27022705
],
27032706
consecutive=True,
27042707
)
2708+
2709+
2710+
class TestRequireUniqueParametrizationtIds:
2711+
@staticmethod
2712+
def _fnmatch_escape_repr(obj: Sequence[tuple[int]]) -> str:
2713+
return re.sub(r"[*?[\]]", (lambda m: f"[{m.group()}]"), repr(obj))
2714+
2715+
def _assert_duplicate_msg(
2716+
self,
2717+
result: RunResult,
2718+
expected_indices: dict[str, Sequence[int]],
2719+
argument_values: Sequence[str],
2720+
resolved_ids: Sequence[str],
2721+
) -> None:
2722+
stream = result.stdout
2723+
stream.fnmatch_lines(
2724+
[
2725+
"Duplicate parametrization IDs detected, but --require-unique-parametrization-ids is set.",
2726+
"Test name: *::test1",
2727+
"Argument names: [[]'x', 'y'[]]",
2728+
f"Argument values: {argument_values}",
2729+
f"Resolved IDs: {resolved_ids}",
2730+
f"Duplicates: {expected_indices}",
2731+
"You can fix this problem using:",
2732+
"the `ids` argument of `@pytest.mark.parametrize()` or the `id` argument of `pytest.param()`.",
2733+
]
2734+
)
2735+
assert result.ret != 0
2736+
2737+
def _convert_to_resolved_ids(
2738+
self, parametrize_args: Sequence[tuple[int, int]]
2739+
) -> Sequence[str]:
2740+
return [f"{x}-{y}" for (x, y) in parametrize_args]
2741+
2742+
def _convert_to_argument_values(
2743+
self, parametrize_args: Sequence[tuple[int, int]]
2744+
) -> Sequence[str]:
2745+
return [str(t) for t in parametrize_args]
2746+
2747+
# Change the test data to native Python types
2748+
CASES = [
2749+
([(1, 1), (1, 1)], {"1-1": [0, 1]}),
2750+
([(1, 1), (1, 2), (1, 1)], {"1-1": [0, 2]}),
2751+
([(1, 1), (2, 2), (1, 1)], {"1-1": [0, 2]}),
2752+
([(1, 1), (2, 2), (1, 2), (2, 1), (1, 1)], {"1-1": [0, 4]}),
2753+
]
2754+
2755+
@pytest.mark.parametrize(("parametrize_args", "expected_indices"), CASES)
2756+
def test_cli_enables(
2757+
self,
2758+
pytester: Pytester,
2759+
parametrize_args: Sequence[tuple[int, int]],
2760+
expected_indices: dict[str, Sequence[int]],
2761+
) -> None:
2762+
pytester.makepyfile(
2763+
f"""
2764+
import pytest
2765+
2766+
@pytest.mark.parametrize('x, y', {parametrize_args})
2767+
def test1(y, x):
2768+
pass
2769+
"""
2770+
)
2771+
result = pytester.runpytest("--require-unique-parametrization-ids")
2772+
resolved_ids = self._convert_to_resolved_ids(parametrize_args)
2773+
argument_values = self._convert_to_argument_values(parametrize_args)
2774+
self._assert_duplicate_msg(
2775+
result, expected_indices, argument_values, resolved_ids
2776+
)
2777+
2778+
@pytest.mark.parametrize("parametrize_args, expected_indices", CASES)
2779+
def test_ini_enables(
2780+
self,
2781+
pytester: Pytester,
2782+
parametrize_args: Sequence[tuple[int, int]],
2783+
expected_indices: dict[str, Sequence[int]],
2784+
) -> None:
2785+
pytester.makeini(
2786+
"""
2787+
[pytest]
2788+
require_unique_parametrization_ids = true
2789+
"""
2790+
)
2791+
pytester.makepyfile(
2792+
f"""
2793+
import pytest
2794+
2795+
@pytest.mark.parametrize('x, y', {parametrize_args})
2796+
def test1(y, x):
2797+
pass
2798+
"""
2799+
)
2800+
result = pytester.runpytest()
2801+
resolved_ids = self._convert_to_resolved_ids(parametrize_args)
2802+
argument_values = self._convert_to_argument_values(parametrize_args)
2803+
self._assert_duplicate_msg(
2804+
result, expected_indices, argument_values, resolved_ids
2805+
)
2806+
2807+
def test_cli_overrides_ini_false(self, pytester: Pytester) -> None:
2808+
"""CLI True should override ini False."""
2809+
pytester.makeini(
2810+
"""
2811+
[pytest]
2812+
require_unique_parametrization_ids = false
2813+
"""
2814+
)
2815+
pytester.makepyfile(
2816+
"""
2817+
import pytest
2818+
2819+
@pytest.mark.parametrize('x, y', [(1,1), (1,1)])
2820+
def test1(y, x):
2821+
pass
2822+
"""
2823+
)
2824+
result = pytester.runpytest("--require-unique-parametrization-ids")
2825+
parametrization_args = [(1, 1), (1, 1)]
2826+
resolved_ids = self._convert_to_resolved_ids(parametrization_args)
2827+
argument_values = self._convert_to_argument_values(parametrization_args)
2828+
self._assert_duplicate_msg(
2829+
result, {"1-1": [0, 1]}, argument_values, resolved_ids
2830+
)

0 commit comments

Comments
 (0)