Repository: pytest-dev/pytest
Pull request: fix #13031: use the concrete id NOTSET for the empty parameter list


this ensures we dont invoke idfunc with the internal NOTSET enum token

closes #13031
<!--
Thanks for submitting a PR, your contribution is really appreciated!

Here is a quick checklist that should be present in PRs.

- [X] Include documentation when adding new features.
- [X] Include new tests or update existing tests when applicable.
- [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

- [X] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

- [X] Create a new changelog file in the `changelog` folder, with a name like `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/main/changelog/README.rst) for details.

  Write sentences in the **past or present tense**, examples:

  * *Improved verbose diff output with sequences.*
  * *Terminal summary statistics now use multiple colors.*

  Also make sure to end the sentence with a `.`.

- [X] Add yourself to `AUTHORS` in alphabetical order.
-->


Problem statement:
Parametrizing a test with an empty list calls `ids` callback with the private constant `NOTSET`
I parametrize tests based on a user-provided list. Sometimes this list is empty, which disables some tests as a result. However, I would expect the `ids` callback that I provide to not be called at all in that case, but it is actually called with the private `NOTSET` sentinel. As this is not exported, I can't easily check for it in my `ids` function and return a useless value to match it.

### Reproduction:

`conftest.py`
```python
import logging

def idmap(obj):
    logging.info(obj)

def pytest_generate_tests(metafunc):
    if "arg" in metafunc.fixturenames:
        metafunc.parametrize(
            "arg", [], ids=idmap
        )
```

`test_demo.py`
```python
def test_demo(arg):
    pass
```

```
$: pytest --collect-only --log-cli-level=DEBUG
...
INFO     root:conftest.py:4 NotSetType.token
...
```

Python: 3.10.15
pytest: 8.3.4
OS: macOS Sonoma 14.6.1

Hints from issue discussion:
For empty id lists we generate a single test with a warning 
Yes, which is reasonable - but I can't detect this in my ID mapping function because it's a hidden type. Perhaps the sentinel can just be exported? Or don't call the ID map for the "no parametrization" case.

I can make a PR in either case!
https://github.com/pytest-dev/pytest/blob/main/src%2F_pytest%2Fmark%2Fstructures.py#L182-L186
I don't quite understand - I don't get access to the ParameterSet, and thus can't see its marks. I simply get `NOTSET` passed to the `ids` callback. Because `NOTSET` is internal to Pytest, I cannot compare the arg against it nor type annotate my ids function to accept it.
indeed - we should use a string that indicates it

i have a wip pr that sets the string NOTSET as direct id to avoid this - 'll polish it a litlle and push

Commit message: fix #13031: use the concrete id NOTSET for the empty parameter list stand-in

this ensures we dont invoke idfunc with the internal NOTSET enum token

Patch to review:
diff --git a/changelog/13031.improvement.rst b/changelog/13031.improvement.rst
new file mode 100644
index 00000000000..30ae4f70852
--- /dev/null
+++ b/changelog/13031.improvement.rst
@@ -0,0 +1,2 @@
+``pytest.mark.parametrize([], idfunc=...)`` will no longer trigger a call to ``idfunc`` with internal objects.
+instead the concrete id ``NOTSET`` is used.
diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py
index 624b37cab94..1a0b3c5b5b8 100644
--- a/src/_pytest/mark/structures.py
+++ b/src/_pytest/mark/structures.py
@@ -47,18 +47,18 @@ def get_empty_parameterset_mark(
 ) -> MarkDecorator:
     from ..nodes import Collector
 
+    argslisting = ", ".join(argnames)
+
     fs, lineno = getfslineno(func)
-    reason = f"got empty parameter set {argnames!r}, function {func.__name__} at {fs}:{lineno}"
+    reason = f"got empty parameter set for ({argslisting})"
     requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION)
     if requested_mark in ("", None, "skip"):
         mark = MARK_GEN.skip(reason=reason)
     elif requested_mark == "xfail":
         mark = MARK_GEN.xfail(reason=reason, run=False)
     elif requested_mark == "fail_at_collect":
-        f_name = func.__name__
-        _, lineno = getfslineno(func)
         raise Collector.CollectError(
-            f"Empty parameter set in '{f_name}' at line {lineno + 1}"
+            f"Empty parameter set in '{func.__name__}' at line {lineno + 1}"
         )
     else:
         raise LookupError(requested_mark)
@@ -181,7 +181,9 @@ 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="NOTSET"
+                )
             )
         return argnames, parameters
 
diff --git a/testing/test_mark.py b/testing/test_mark.py
index 60ee795cf43..a9ba416d127 100644
--- a/testing/test_mark.py
+++ b/testing/test_mark.py
@@ -1048,6 +1048,31 @@ def test():
     assert result.ret == ExitCode.INTERRUPTED
 
 
+def test_paramset_empty_no_idfunc(
+    pytester: Pytester, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    p1 = pytester.makepyfile(
+        """
+        import pytest
+
+        def idfunc(value):
+            raise ValueError()
+        @pytest.mark.parametrize("param", [], ids=idfunc)
+        def test(param):
+            pass
+        """
+    )
+    result = pytester.runpytest(p1, "-v", "-rs")
+    result.stdout.fnmatch_lines(
+        [
+            "* collected 1 item",
+            "test_paramset_empty_no_idfunc* SKIPPED *",
+            "SKIPPED [1] test_paramset_empty_no_idfunc.py:5: got empty parameter set for (param)",
+            "*= 1 skipped in *",
+        ]
+    )
+
+
 def test_parameterset_for_parametrize_bad_markname(pytester: Pytester) -> None:
     with pytest.raises(pytest.UsageError):
         test_parameterset_for_parametrize_marks(pytester, "bad")


Code review comments:
diff_hunk: @@ -0,0 +1,2 @@
+``pytest.mark.parametrize([], idfunc=...)`` will no longer trigger a call to ``idfunc`` with internal objects.
path: changelog/13031.improvement.rst
text: @user1:
```suggestion
An empty parameter set as in ``pytest.mark.parametrize([], ids=idfunc)`` will no longer trigger a call to ``idfunc`` with internal objects.
```

diff_hunk: @@ -0,0 +1,2 @@
+``pytest.mark.parametrize([], idfunc=...)`` will no longer trigger a call to ``idfunc`` with internal objects.
+instead the concrete id ``NOTSET`` is used.
path: changelog/13031.improvement.rst
text: @user1:
Seems like a left-over, as it does not affect the user?

```suggestion
```