diff --git a/changelog/14739.improvement.rst b/changelog/14739.improvement.rst new file mode 100644 index 00000000000..83e70d35a56 --- /dev/null +++ b/changelog/14739.improvement.rst @@ -0,0 +1 @@ +IdMaker.make_unique_parameterset_ids (used to generate ids for @pytest.mark.parametrize) no longer scales quadratically with the number of parameter sets when many of them produce colliding ids, significantly speeding up collection for large parametrized test suites with many duplicate ids. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 594182892e6..b8edc970b97 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -930,7 +930,8 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: """ resolved_ids = list(self._resolve_ids()) # All IDs must be unique! - if len(resolved_ids) != len(set(resolved_ids)): + all_ids = set(resolved_ids) + if len(resolved_ids) != len(all_ids): # Record the number of occurrences of each ID. id_counts = Counter(resolved_ids) @@ -963,6 +964,8 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: # Map the ID to its next suffix. id_suffixes: dict[str, int] = defaultdict(int) # Suffix non-unique IDs to make them unique. + used_ids = set(all_ids) + remaining_id_counts = id_counts.copy() for index, id in enumerate(resolved_ids): if id_counts[id] > 1: if id is HIDDEN_PARAM: @@ -971,10 +974,14 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: if id and id[-1].isdigit(): suffix = "_" new_id = f"{id}{suffix}{id_suffixes[id]}" - while new_id in set(resolved_ids): + while new_id in used_ids: id_suffixes[id] += 1 new_id = f"{id}{suffix}{id_suffixes[id]}" + remaining_id_counts[id] -= 1 + if remaining_id_counts[id] == 0: + used_ids.discard(id) resolved_ids[index] = new_id + used_ids.add(new_id) id_suffixes[id] += 1 assert len(resolved_ids) == len(set(resolved_ids)), ( f"Internal error: {resolved_ids=}" diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index d4c3e514c9d..c6ac05d7146 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -785,6 +785,39 @@ def test_idmaker_duplicated_empty_str(self) -> None: ).make_unique_parameterset_ids() assert result == ["0", "1"] + def test_idmaker_unique_ids_frees_renamed_away_ids(self) -> None: + """Suffix collision must see ids freed after all duplicates are renamed. + + Simplified resolved-id sequence: ["a10", "a10"] + ["a"] * 11 + After renaming both "a10" entries, "a10" must be available again for + the 11th "a". A set that only .add()s new ids and never discards old + ones incorrectly yields "a11" as the last id. + """ + ids = ["a10", "a10"] + ["a"] * 11 + result = IdMaker( + ("x",), + [pytest.param(i) for i in range(len(ids))], + None, + ids, + None, + None, + ).make_unique_parameterset_ids() + assert result == [ + "a10_0", + "a10_1", + "a0", + "a1", + "a2", + "a3", + "a4", + "a5", + "a6", + "a7", + "a8", + "a9", + "a10", + ] + def test_parametrize_ids_exception(self, pytester: Pytester) -> None: """ :param pytester: the instance of Pytester class, a temporary