From 1a98029feff60d82c0ac467a0e78738821c5c8c9 Mon Sep 17 00:00:00 2001 From: Abdulhamid Rustamov Date: Tue, 2 Jun 2026 08:47:57 +0500 Subject: [PATCH 1/2] fix: prevent NOTSET sentinel leaking as test ID in parametrize with callable ids When @pytest.mark.parametrize is used with an empty argvalues list and a callable ids parameter, pytest internally creates a placeholder ParameterSet with NOTSET sentinel values. Previously this placeholder used id='NOTSET', which caused the internal string to leak into the test output as 'test_foo[NOTSET]'. Additionally, if an ids callable was provided, it would be invoked with the NOTSET sentinel and crash with an AttributeError or similar. Fix: - Use id=None for the placeholder ParameterSet so the standard ID generation runs (produces e.g. 'test_foo[x0]' matching the existing workaround behavior) - Guard _idval_from_function to return None early when val is NOTSET, preventing the user's callable from being invoked with the sentinel Fixes #13235 --- changelog/13235.bugfix.rst | 5 +++++ src/_pytest/mark/structures.py | 2 +- src/_pytest/python.py | 2 ++ testing/python/metafunc.py | 15 +++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 changelog/13235.bugfix.rst diff --git a/changelog/13235.bugfix.rst b/changelog/13235.bugfix.rst new file mode 100644 index 00000000000..88ef80a8e2d --- /dev/null +++ b/changelog/13235.bugfix.rst @@ -0,0 +1,5 @@ +Fixed :func:`pytest.mark.parametrize` with an empty ``argvalues`` list and a +callable ``ids`` parameter leaking the internal ``NOTSET`` string as the test +ID (e.g. ``test_foo[NOTSET]``). The callable is no longer invoked for the +placeholder parameterset, and the generated ID now follows the standard +auto-naming convention (e.g. ``test_foo[x0]``). diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 35fd24f8f96..f57d4211c45 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -238,7 +238,7 @@ def _for_parametrize( mark = get_empty_parameterset_mark(config, argnames, func) parameters.append( ParameterSet( - values=(NOTSET,) * len(argnames), marks=[mark], id="NOTSET" + values=(NOTSET,) * len(argnames), marks=[mark], id=None ) ) return argnames, parameters diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ad5a2c6a59b..b8e152a2131 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1013,6 +1013,8 @@ def _idval_from_function(self, val: object, argname: str, idx: int) -> str | Non user-provided id callable, if given.""" if self.idfn is None: return None + if val is NOTSET: + return None try: id = self.idfn(val) except Exception as e: diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 026589d65f5..fa91e433d39 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1475,6 +1475,21 @@ def test_temp(temp): result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 skipped *"]) + def test_parametrize_empty_argvalues_callable_ids(self, pytester: Pytester) -> None: + """Callable ids with empty argvalues must not crash or leak 'NOTSET' (#13235).""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [], ids=lambda x: x.id) + def test_foo(x): + pass + """ + ) + result = pytester.runpytest("-v") + result.stdout.no_fnmatch_line("*NOTSET*") + result.stdout.fnmatch_lines(["* 1 skipped *"]) + def test_parametrized_ids_invalid_type(self, pytester: Pytester) -> None: """Test error with non-strings/non-ints, without generator (#1857).""" pytester.makepyfile( From 3945b98f8fafdc6dd13a7387973fc96332fa6be2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:48:50 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/mark/structures.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index f57d4211c45..6a8f3cd84ed 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -237,9 +237,7 @@ def _for_parametrize( # parameter set with NOTSET values, with the "empty parameter set" mark applied to it. mark = get_empty_parameterset_mark(config, argnames, func) parameters.append( - ParameterSet( - values=(NOTSET,) * len(argnames), marks=[mark], id=None - ) + ParameterSet(values=(NOTSET,) * len(argnames), marks=[mark], id=None) ) return argnames, parameters