From 06f6de91991752f11033dabd91a40cfae5d70c10 Mon Sep 17 00:00:00 2001 From: Ahmed Tabish Date: Tue, 21 Jul 2026 00:44:29 +0530 Subject: [PATCH 1/3] Avoid quadratic behavior in IdMaker.make_unique_parameterset_ids make_unique_parameterset_ids rebuilt set(resolved_ids) from scratch on every candidate suffix it tried while de-duplicating parametrize ids. For parametrizations with many colliding ids this made the method scale O(n^2) with the number of parameter sets. Build the set of in-use ids once and keep it updated incrementally instead, bringing this down to linear time. Confirmed with a manual benchmark, runtime dropped from ~0.64s to ~0.01s, and the doubling ratio changed from ~4x (quadratic) to ~2x (linear). --- src/_pytest/python.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index eb481393ba1..251af4a91bf 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -912,7 +912,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) @@ -945,6 +946,7 @@ 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 = all_ids for index, id in enumerate(resolved_ids): if id_counts[id] > 1: if id is HIDDEN_PARAM: @@ -953,10 +955,11 @@ 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]}" 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=}" From 1f0cfd830aaf511c4f38e761192a17438b58e409 Mon Sep 17 00:00:00 2001 From: Ahmed Tabish Date: Tue, 21 Jul 2026 00:59:28 +0530 Subject: [PATCH 2/3] Add changelog entry for #14739 --- changelog/14739.improvement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14739.improvement.rst 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. From 2f4e877b5bf4baeb9526df865d6f82b0b270d1be Mon Sep 17 00:00:00 2001 From: Ahmed Tabish Date: Tue, 21 Jul 2026 23:04:37 +0530 Subject: [PATCH 3/3] Address review: free renamed-away ids from used_ids, add regression test --- src/_pytest/python.py | 6 +++++- testing/python/metafunc.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e83bfca5c09..b8edc970b97 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -964,7 +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 = all_ids + 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: @@ -976,6 +977,9 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: 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 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