Skip to content

Commit 40ea236

Browse files
authored
Merge branch 'main' into fix-13537
2 parents ff07ff6 + ebcd836 commit 40ea236

File tree

10 files changed

+301
-85
lines changed

10 files changed

+301
-85
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ Oliver Bestwalter
346346
Olivier Grisel
347347
Omar Kohl
348348
Omer Hadari
349+
Omri Golan
349350
Ondřej Súkup
350351
Oscar Benjamin
351352
Parth Patel

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 :confval:`strict_parametrization_ids` configuration option.
2+
3+
When set, pytest emits 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/plugin_list.rst

Lines changed: 112 additions & 80 deletions
Large diffs are not rendered by default.

doc/en/reference/reference.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,6 +2082,44 @@ passed multiple times. The expected format is ``name=value``. For example::
20822082
[pytest]
20832083
xfail_strict = True
20842084
2085+
.. confval:: strict_parametrization_ids
2086+
2087+
If set, pytest emits an error if it detects non-unique parameter set IDs.
2088+
2089+
If not set (the default), pytest automatically handles this by adding `0`, `1`, ... to duplicate IDs,
2090+
making them unique.
2091+
2092+
.. code-block:: ini
2093+
2094+
[pytest]
2095+
strict_parametrization_ids = True
2096+
2097+
For example,
2098+
2099+
.. code-block:: python
2100+
2101+
import pytest
2102+
2103+
2104+
@pytest.mark.parametrize("letter", ["a", "a"])
2105+
def test_letter_is_ascii(letter):
2106+
assert letter.isascii()
2107+
2108+
will emit an error because both cases (parameter sets) have the same auto-generated ID "a".
2109+
2110+
To fix the error, if you decide to keep the duplicates, explicitly assign unique IDs:
2111+
2112+
.. code-block:: python
2113+
2114+
import pytest
2115+
2116+
2117+
@pytest.mark.parametrize("letter", ["a", "a"], ids=["a0", "a1"])
2118+
def test_letter_is_ascii(letter):
2119+
assert letter.isascii()
2120+
2121+
See :func:`parametrize <pytest.Metafunc.parametrize>` and :func:`pytest.param` for other ways to set IDs.
2122+
20852123

20862124
.. _`command-line-flags`:
20872125

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ norecursedirs = [
379379
"dist",
380380
]
381381
xfail_strict = true
382+
strict_parametrization_ids = true
382383
filterwarnings = [
383384
"error",
384385
"default:Using or importing the ABCs:DeprecationWarning:unittest2.*",

src/_pytest/python.py

Lines changed: 44 additions & 2 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
@@ -107,6 +108,12 @@ def pytest_addoption(parser: Parser) -> None:
107108
help="Disable string escape non-ASCII characters, might cause unwanted "
108109
"side effects(use at your own risk)",
109110
)
111+
parser.addini(
112+
"strict_parametrization_ids",
113+
type="bool",
114+
default=False,
115+
help="Emit an error if non-unique parameter set IDs are detected",
116+
)
110117

111118

112119
def pytest_generate_tests(metafunc: Metafunc) -> None:
@@ -878,8 +885,8 @@ class IdMaker:
878885
# Optionally, explicit IDs for ParameterSets by index.
879886
ids: Sequence[object | None] | None
880887
# Optionally, the pytest config.
881-
# Used for controlling ASCII escaping, and for calling the
882-
# :hook:`pytest_make_parametrize_id` hook.
888+
# Used for controlling ASCII escaping, determining parametrization ID
889+
# strictness, and for calling the :hook:`pytest_make_parametrize_id` hook.
883890
config: Config | None
884891
# Optionally, the ID of the node being parametrized.
885892
# Used only for clearer error messages.
@@ -892,6 +899,9 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
892899
"""Make a unique identifier for each ParameterSet, that may be used to
893900
identify the parametrization in a node ID.
894901
902+
If strict_parametrization_ids is enabled, and duplicates are detected,
903+
raises CollectError. Otherwise makes the IDs unique as follows:
904+
895905
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
896906
- user-provided id, if given
897907
- else an id derived from the value, applicable for certain types
@@ -904,6 +914,33 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
904914
if len(resolved_ids) != len(set(resolved_ids)):
905915
# Record the number of occurrences of each ID.
906916
id_counts = Counter(resolved_ids)
917+
918+
if self._strict_parametrization_ids_enabled():
919+
parameters = ", ".join(self.argnames)
920+
parametersets = ", ".join(
921+
[saferepr(list(param.values)) for param in self.parametersets]
922+
)
923+
ids = ", ".join(
924+
id if id is not HIDDEN_PARAM else "<hidden>" for id in resolved_ids
925+
)
926+
duplicates = ", ".join(
927+
id if id is not HIDDEN_PARAM else "<hidden>"
928+
for id, count in id_counts.items()
929+
if count > 1
930+
)
931+
msg = textwrap.dedent(f"""
932+
Duplicate parametrization IDs detected, but strict_parametrization_ids is set.
933+
934+
Test name: {self.nodeid}
935+
Parameters: {parameters}
936+
Parameter sets: {parametersets}
937+
IDs: {ids}
938+
Duplicates: {duplicates}
939+
940+
You can fix this problem using `@pytest.mark.parametrize(..., ids=...)` or `pytest.param(..., id=...)`.
941+
""").strip() # noqa: E501
942+
raise nodes.Collector.CollectError(msg)
943+
907944
# Map the ID to its next suffix.
908945
id_suffixes: dict[str, int] = defaultdict(int)
909946
# Suffix non-unique IDs to make them unique.
@@ -925,6 +962,11 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
925962
)
926963
return resolved_ids
927964

965+
def _strict_parametrization_ids_enabled(self) -> bool:
966+
if self.config:
967+
return bool(self.config.getini("strict_parametrization_ids"))
968+
return False
969+
928970
def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
929971
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
930972
for idx, parameterset in enumerate(self.parametersets):

testing/_py/test_local.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def test_visit_norecurse(self, path1):
209209

210210
@pytest.mark.parametrize(
211211
"fil",
212-
["*dir", "*dir", pytest.mark.skip("sys.version_info < (3,6)")(b"*dir")],
212+
["*dir", pytest.mark.skip("sys.version_info < (3,6)")(b"*dir")],
213213
)
214214
def test_visit_filterfunc_is_string(self, path1, fil):
215215
lst = []

testing/test_collection.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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
@@ -2702,3 +2703,94 @@ def test_1(): pass
27022703
],
27032704
consecutive=True,
27042705
)
2706+
2707+
2708+
@pytest.mark.parametrize(
2709+
["x_y", "expected_duplicates"],
2710+
[
2711+
(
2712+
[(1, 1), (1, 1)],
2713+
["1-1"],
2714+
),
2715+
(
2716+
[(1, 1), (1, 2), (1, 1)],
2717+
["1-1"],
2718+
),
2719+
(
2720+
[(1, 1), (2, 2), (1, 1)],
2721+
["1-1"],
2722+
),
2723+
(
2724+
[(1, 1), (2, 2), (1, 2), (2, 1), (1, 1), (2, 1)],
2725+
["1-1", "2-1"],
2726+
),
2727+
],
2728+
)
2729+
def test_strict_parametrization_ids(
2730+
pytester: Pytester,
2731+
x_y: Sequence[tuple[int, int]],
2732+
expected_duplicates: Sequence[str],
2733+
) -> None:
2734+
pytester.makeini(
2735+
"""
2736+
[pytest]
2737+
strict_parametrization_ids = true
2738+
"""
2739+
)
2740+
pytester.makepyfile(
2741+
f"""
2742+
import pytest
2743+
2744+
@pytest.mark.parametrize(["x", "y"], {x_y})
2745+
def test1(x, y):
2746+
pass
2747+
"""
2748+
)
2749+
2750+
result = pytester.runpytest()
2751+
2752+
assert result.ret == ExitCode.INTERRUPTED
2753+
expected_parametersets = ", ".join(str(list(p)) for p in x_y)
2754+
expected_ids = ", ".join(f"{x}-{y}" for x, y in x_y)
2755+
result.stdout.fnmatch_lines(
2756+
[
2757+
"Duplicate parametrization IDs detected*",
2758+
"",
2759+
"Test name: *::test1",
2760+
"Parameters: x, y",
2761+
f"Parameter sets: {expected_parametersets}",
2762+
f"IDs: {expected_ids}",
2763+
f"Duplicates: {', '.join(expected_duplicates)}",
2764+
"",
2765+
"You can fix this problem using *",
2766+
]
2767+
)
2768+
2769+
2770+
def test_strict_parametrization_ids_with_hidden_param(pytester: Pytester) -> None:
2771+
pytester.makeini(
2772+
"""
2773+
[pytest]
2774+
strict_parametrization_ids = true
2775+
"""
2776+
)
2777+
pytester.makepyfile(
2778+
"""
2779+
import pytest
2780+
2781+
@pytest.mark.parametrize(["x"], ["a", pytest.param("a", id=pytest.HIDDEN_PARAM), "a"])
2782+
def test1(x):
2783+
pass
2784+
"""
2785+
)
2786+
2787+
result = pytester.runpytest()
2788+
2789+
assert result.ret == ExitCode.INTERRUPTED
2790+
result.stdout.fnmatch_lines(
2791+
[
2792+
"Duplicate parametrization IDs detected*",
2793+
"IDs: a, <hidden>, a",
2794+
"Duplicates: a",
2795+
]
2796+
)

testing/test_doctest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,14 @@ def __getattr__(self, _):
15961596

15971597

15981598
@pytest.mark.parametrize( # pragma: no branch (lambdas are not called)
1599-
"stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True]
1599+
"stop",
1600+
[
1601+
None,
1602+
pytest.param(_is_mocked, id="is_mocked"),
1603+
pytest.param(lambda f: None, id="lambda_none"),
1604+
pytest.param(lambda f: False, id="lambda_false"),
1605+
pytest.param(lambda f: True, id="lambda_true"),
1606+
],
16001607
)
16011608
def test_warning_on_unwrap_of_broken_object(
16021609
stop: Callable[[object], object] | None,

testing/test_mark_expression.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ def test_empty_is_false() -> None:
2020
@pytest.mark.parametrize(
2121
("expr", "expected"),
2222
(
23-
("true", True),
2423
("true", True),
2524
("false", False),
2625
("not true", False),

0 commit comments

Comments
 (0)