From 189d251f2ec332defc3cfafa796181b5703c6847 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 17 Feb 2025 01:57:55 +0300 Subject: [PATCH 01/11] Allow hiding a parameter set from test name Resolves: #13228 --- AUTHORS | 1 + changelog/13228.feature.rst | 1 + src/_pytest/mark/__init__.py | 2 + src/_pytest/mark/structures.py | 17 ++++- src/_pytest/python.py | 53 ++++++++++----- src/pytest/__init__.py | 2 + testing/python/metafunc.py | 119 +++++++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 changelog/13228.feature.rst diff --git a/AUTHORS b/AUTHORS index e670571566a..e5c19cdca0d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -43,6 +43,7 @@ Anthony Shaw Anthony Sottile Anton Grinevich Anton Lodder +Anton Zhilin Antony Lee Arel Cordero Arias Emmanuel diff --git a/changelog/13228.feature.rst b/changelog/13228.feature.rst new file mode 100644 index 00000000000..f8935719c5a --- /dev/null +++ b/changelog/13228.feature.rst @@ -0,0 +1 @@ +``pytest.HIDDEN_PARAM`` can now be used in ``id`` of ``pytest.param`` or in ``ids`` of ``parametrize``. It hides the parameter set from the test name. diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 488b562a298..e43a24f230c 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -14,6 +14,7 @@ from .expression import ParseError from .structures import EMPTY_PARAMETERSET_OPTION from .structures import get_empty_parameterset_mark +from .structures import HIDDEN_PARAM from .structures import Mark from .structures import MARK_GEN from .structures import MarkDecorator @@ -33,6 +34,7 @@ __all__ = [ + "HIDDEN_PARAM", "MARK_GEN", "Mark", "MarkDecorator", diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 1a0b3c5b5b8..03f4919ecf8 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -10,6 +10,7 @@ from collections.abc import MutableMapping from collections.abc import Sequence import dataclasses +import enum import inspect from typing import Any from typing import final @@ -65,17 +66,27 @@ def get_empty_parameterset_mark( return mark +# Singleton type for NOTSET, as described in: +# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions +class _HiddenParam(enum.Enum): + token = 0 + + +#: Can be used as a parameter set id to hide it from the test name. +HIDDEN_PARAM = _HiddenParam.token + + class ParameterSet(NamedTuple): values: Sequence[object | NotSetType] marks: Collection[MarkDecorator | Mark] - id: str | None + id: str | _HiddenParam | None @classmethod def param( cls, *values: object, marks: MarkDecorator | Collection[MarkDecorator | Mark] = (), - id: str | None = None, + id: str | _HiddenParam | None = None, ) -> ParameterSet: if isinstance(marks, MarkDecorator): marks = (marks,) @@ -88,7 +99,7 @@ def param( ) if id is not None: - if not isinstance(id, str): + if not isinstance(id, str) and id is not HIDDEN_PARAM: raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}") return cls(values, marks, id) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ef8a5f02b53..717e172d4e1 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -25,6 +25,7 @@ from typing import Any from typing import final from typing import Literal +from typing import NoReturn from typing import TYPE_CHECKING import warnings @@ -57,6 +58,7 @@ from _pytest.main import Session from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks +from _pytest.mark.structures import HIDDEN_PARAM from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list @@ -473,13 +475,14 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: - subname = f"{name}[{callspec.id}]" + params_id = callspec.id + subname = f"{name}[{params_id}]" if params_id else name yield Function.from_parent( self, name=subname, callspec=callspec, fixtureinfo=fixtureinfo, - keywords={callspec.id: True}, + keywords={params_id: True}, originalname=name, ) @@ -884,7 +887,7 @@ class IdMaker: # Used only for clearer error messages. func_name: str | None - def make_unique_parameterset_ids(self) -> list[str]: + def make_unique_parameterset_ids(self) -> list[str | None]: """Make a unique identifier for each ParameterSet, that may be used to identify the parametrization in a node ID. @@ -905,6 +908,8 @@ def make_unique_parameterset_ids(self) -> list[str]: # Suffix non-unique IDs to make them unique. for index, id in enumerate(resolved_ids): if id_counts[id] > 1: + if id is None: + self._complain_multiple_hidden_parameter_sets() suffix = "" if id and id[-1].isdigit(): suffix = "_" @@ -919,15 +924,21 @@ def make_unique_parameterset_ids(self) -> list[str]: ) return resolved_ids - def _resolve_ids(self) -> Iterable[str]: + def _resolve_ids(self) -> Iterable[str | None]: """Resolve IDs for all ParameterSets (may contain duplicates).""" for idx, parameterset in enumerate(self.parametersets): if parameterset.id is not None: # ID provided directly - pytest.param(..., id="...") - yield _ascii_escaped_by_config(parameterset.id, self.config) + if parameterset.id is HIDDEN_PARAM: + yield None + else: + yield _ascii_escaped_by_config(parameterset.id, self.config) elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: # ID provided in the IDs list - parametrize(..., ids=[...]). - yield self._idval_from_value_required(self.ids[idx], idx) + if self.ids[idx] is HIDDEN_PARAM: + yield None + else: + yield self._idval_from_value_required(self.ids[idx], idx) else: # ID not provided - generate it. yield "-".join( @@ -1001,12 +1012,7 @@ def _idval_from_value_required(self, val: object, idx: int) -> str: return id # Fail. - if self.func_name is not None: - prefix = f"In {self.func_name}: " - elif self.nodeid is not None: - prefix = f"In {self.nodeid}: " - else: - prefix = "" + prefix = self._make_error_prefix() msg = ( f"{prefix}ids contains unsupported value {saferepr(val)} (type: {type(val)!r}) at index {idx}. " "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__." @@ -1019,6 +1025,21 @@ def _idval_from_argname(argname: str, idx: int) -> str: and the index of the ParameterSet.""" return str(argname) + str(idx) + def _complain_multiple_hidden_parameter_sets(self) -> NoReturn: + fail( + f"{self._make_error_prefix()}multiple instances of HIDDEN_PARAM " + "cannot be used in the same parametrize call, " + "because the tests names need to be unique." + ) + + def _make_error_prefix(self) -> str: + if self.func_name is not None: + return f"In {self.func_name}: " + elif self.nodeid is not None: + return f"In {self.nodeid}: " + else: + return "" + @final @dataclasses.dataclass(frozen=True) @@ -1047,7 +1068,7 @@ def setmulti( *, argnames: Iterable[str], valset: Iterable[object], - id: str, + id: str | None, marks: Iterable[Mark | MarkDecorator], scope: Scope, param_index: int, @@ -1065,7 +1086,7 @@ def setmulti( params=params, indices=indices, _arg2scope=arg2scope, - _idlist=[*self._idlist, id], + _idlist=[*self._idlist, id] if id else self._idlist, marks=[*self.marks, *normalize_mark_list(marks)], ) @@ -1189,6 +1210,8 @@ def parametrize( ``bool``, or ``None``. They are mapped to the corresponding index in ``argvalues``. ``None`` means to use the auto-generated id. + ``pytest.HIDDEN_PARAM`` means to hide the parameter set + from the test name. If it is a callable it will be called for each entry in ``argvalues``, and the return value is used as part of the @@ -1322,7 +1345,7 @@ def _resolve_parameter_set_ids( ids: Iterable[object | None] | Callable[[Any], object | None] | None, parametersets: Sequence[ParameterSet], nodeid: str, - ) -> list[str]: + ) -> list[str | None]: """Resolve the actual ids for the given parameter sets. :param argnames: diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 70096d6593e..f81b8cea1db 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -33,6 +33,7 @@ from _pytest.logging import LogCaptureFixture from _pytest.main import Dir from _pytest.main import Session +from _pytest.mark import HIDDEN_PARAM from _pytest.mark import Mark from _pytest.mark import MARK_GEN as mark from _pytest.mark import MarkDecorator @@ -89,6 +90,7 @@ __all__ = [ + "HIDDEN_PARAM", "Cache", "CallInfo", "CaptureFixture", diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 4e7e441768c..503c8bc9970 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -2143,3 +2143,122 @@ def test_converted_to_str(a, b): "*= 6 passed in *", ] ) + + +class TestHiddenParam: + """Test that pytest.HIDDEN_PARAM works""" + + def test_parametrize_ids(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + ("a", "x"), + ("b", "y"), + ("c", "z"), + ], + ids=["paramset1", pytest.HIDDEN_PARAM, "paramset3"], + ) + def test_func(foo, bar): + pass + """ + ) + result = pytester.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_parametrize_ids.py::test_func[paramset1] PASSED", + "test_parametrize_ids.py::test_func PASSED", + "test_parametrize_ids.py::test_func[paramset3] PASSED", + "*= 3 passed in *", + ] + ) + + def test_param_id(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + pytest.param("a", "x", id="paramset1"), + pytest.param("b", "y", id=pytest.HIDDEN_PARAM), + ("c", "z"), + ], + ) + def test_func(foo, bar): + pass + """ + ) + result = pytester.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_param_id.py::test_func[paramset1] PASSED", + "test_param_id.py::test_func PASSED", + "test_param_id.py::test_func[c-z] PASSED", + "*= 3 passed in *", + ] + ) + + def test_multiple_hidden_param_is_forbidden(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + ("a", "x"), + ("b", "y"), + ], + ids=[pytest.HIDDEN_PARAM, pytest.HIDDEN_PARAM], + ) + def test_func(foo, bar): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines( + [ + "collected 0 items / 1 error", + "", + "*= ERRORS =*", + "*_ ERROR collecting test_multiple_hidden_param_is_forbidden.py _*", + "E Failed: In test_func: multiple instances of HIDDEN_PARAM cannot be used " + "in the same parametrize call, because the tests names need to be unique.", + "*! Interrupted: 1 error during collection !*", + "*= no tests collected, 1 error in *", + ] + ) + + def test_multiple_parametrize(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize( + "bar", + ["x", "y"], + ) + @pytest.mark.parametrize( + "foo", + ["a", "b"], + ids=["a", pytest.HIDDEN_PARAM], + ) + def test_func(foo, bar): + pass + """ + ) + result = pytester.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_multiple_parametrize.py::test_func[a-x] PASSED", + "test_multiple_parametrize.py::test_func[a-y] PASSED", + "test_multiple_parametrize.py::test_func[x] PASSED", + "test_multiple_parametrize.py::test_func[y] PASSED", + "*= 4 passed in *", + ] + ) From 5c2e9f4a3e3d1e189fae8892553e64772040d6d2 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 17 Feb 2025 02:04:31 +0300 Subject: [PATCH 02/11] Fix a copy-pasted comment --- src/_pytest/mark/structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 03f4919ecf8..2fdb5988d52 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -66,7 +66,7 @@ def get_empty_parameterset_mark( return mark -# Singleton type for NOTSET, as described in: +# Singleton type for HIDDEN_PARAM, as described in: # https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions class _HiddenParam(enum.Enum): token = 0 From ca2a3d0ff32107fb6a2bf02d7ac1420c5a7a2563 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 17 Feb 2025 02:40:33 +0300 Subject: [PATCH 03/11] Fix empty params --- src/_pytest/python.py | 7 +++-- testing/python/metafunc.py | 53 ++++++++++++++++---------------------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 717e172d4e1..70a4786b3f2 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -475,14 +475,13 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: - params_id = callspec.id - subname = f"{name}[{params_id}]" if params_id else name + subname = f"{name}[{callspec.id}]" if callspec._idlist else name yield Function.from_parent( self, name=subname, callspec=callspec, fixtureinfo=fixtureinfo, - keywords={params_id: True}, + keywords={callspec.id: True}, originalname=name, ) @@ -1086,7 +1085,7 @@ def setmulti( params=params, indices=indices, _arg2scope=arg2scope, - _idlist=[*self._idlist, id] if id else self._idlist, + _idlist=[*self._idlist, id] if id is not None else self._idlist, marks=[*self.marks, *normalize_mark_list(marks)], ) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 503c8bc9970..4ede6879218 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -2149,7 +2149,7 @@ class TestHiddenParam: """Test that pytest.HIDDEN_PARAM works""" def test_parametrize_ids(self, pytester: Pytester) -> None: - pytester.makepyfile( + items = pytester.getitems( """ import pytest @@ -2166,18 +2166,15 @@ def test_func(foo, bar): pass """ ) - result = pytester.runpytest("-vv", "-s") - result.stdout.fnmatch_lines( - [ - "test_parametrize_ids.py::test_func[paramset1] PASSED", - "test_parametrize_ids.py::test_func PASSED", - "test_parametrize_ids.py::test_func[paramset3] PASSED", - "*= 3 passed in *", - ] - ) + names = [item.name for item in items] + assert names == [ + "test_func[paramset1]", + "test_func", + "test_func[paramset3]", + ] def test_param_id(self, pytester: Pytester) -> None: - pytester.makepyfile( + items = pytester.getitems( """ import pytest @@ -2193,15 +2190,12 @@ def test_func(foo, bar): pass """ ) - result = pytester.runpytest("-vv", "-s") - result.stdout.fnmatch_lines( - [ - "test_param_id.py::test_func[paramset1] PASSED", - "test_param_id.py::test_func PASSED", - "test_param_id.py::test_func[c-z] PASSED", - "*= 3 passed in *", - ] - ) + names = [item.name for item in items] + assert names == [ + "test_func[paramset1]", + "test_func", + "test_func[c-z]", + ] def test_multiple_hidden_param_is_forbidden(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -2235,7 +2229,7 @@ def test_func(foo, bar): ) def test_multiple_parametrize(self, pytester: Pytester) -> None: - pytester.makepyfile( + items = pytester.getitems( """ import pytest @@ -2252,13 +2246,10 @@ def test_func(foo, bar): pass """ ) - result = pytester.runpytest("-vv", "-s") - result.stdout.fnmatch_lines( - [ - "test_multiple_parametrize.py::test_func[a-x] PASSED", - "test_multiple_parametrize.py::test_func[a-y] PASSED", - "test_multiple_parametrize.py::test_func[x] PASSED", - "test_multiple_parametrize.py::test_func[y] PASSED", - "*= 4 passed in *", - ] - ) + names = [item.name for item in items] + assert names == [ + "test_func[a-x]", + "test_func[a-y]", + "test_func[x]", + "test_func[y]", + ] From 7b2f259ec979da122bf47b5182b74494b36580dc Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 17 Feb 2025 03:12:22 +0300 Subject: [PATCH 04/11] Improve coverage --- testing/python/metafunc.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 4ede6879218..e8b345aecc6 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -19,6 +19,7 @@ from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET from _pytest.outcomes import fail +from _pytest.outcomes import Failed from _pytest.pytester import Pytester from _pytest.python import Function from _pytest.python import IdMaker @@ -2228,6 +2229,20 @@ def test_func(foo, bar): ] ) + def test_multiple_hidden_param_is_forbidden_idmaker(self) -> None: + id_maker = IdMaker( + ("foo", "bar"), + [pytest.param("a", "x"), pytest.param("b", "y")], + None, + [pytest.HIDDEN_PARAM, pytest.HIDDEN_PARAM], + None, + "some_node_id", + None, + ) + expected = "In some_node_id: multiple instances of HIDDEN_PARAM" + with pytest.raises(Failed, match=expected): + id_maker.make_unique_parameterset_ids() + def test_multiple_parametrize(self, pytester: Pytester) -> None: items = pytester.getitems( """ From 72b34417a10d67749f18dd9aab721d5048d54ca9 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Thu, 20 Feb 2025 21:23:54 +0300 Subject: [PATCH 05/11] Fix docs --- changelog/13228.feature.rst | 4 +++- doc/en/reference/reference.rst | 10 ++++++++++ src/_pytest/mark/__init__.py | 9 ++++++++- src/_pytest/python.py | 8 ++++++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/changelog/13228.feature.rst b/changelog/13228.feature.rst index f8935719c5a..fd65f2f2fc9 100644 --- a/changelog/13228.feature.rst +++ b/changelog/13228.feature.rst @@ -1 +1,3 @@ -``pytest.HIDDEN_PARAM`` can now be used in ``id`` of ``pytest.param`` or in ``ids`` of ``parametrize``. It hides the parameter set from the test name. +:ref:`pytest.HIDDEN_PARAM` can now be used in ``id`` of :func:`pytest.param` or in +``ids`` of :py:func:`Metafunc.parametrize `. +It hides the parameter set from the test name. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 809e97b4747..e77e9a54d14 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -20,6 +20,16 @@ The current pytest version, as a string:: >>> pytest.__version__ '7.0.0' +.. _`pytest.HIDDEN_PARAM ref`: + +pytest.HIDDEN_PARAM +~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 8.4 + +Can be passed to ``ids`` of :py:func:`Metafunc.parametrize ` +or to ``id`` of :func:`pytest.param` to hide a parameter set from the test name. +Can only be used at most 1 time, as test names need to be unique. .. _`version-tuple`: diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index e43a24f230c..0e5747d22ba 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -12,6 +12,7 @@ from .expression import Expression from .expression import ParseError +from .structures import _HiddenParam from .structures import EMPTY_PARAMETERSET_OPTION from .structures import get_empty_parameterset_mark from .structures import HIDDEN_PARAM @@ -50,7 +51,7 @@ def param( *values: object, marks: MarkDecorator | Collection[MarkDecorator | Mark] = (), - id: str | None = None, + id: str | _HiddenParam | None = None, ) -> ParameterSet: """Specify a parameter in `pytest.mark.parametrize`_ calls or :ref:`parametrized fixtures `. @@ -75,6 +76,12 @@ def test_eval(test_input, expected): :ref:`pytest.mark.usefixtures ` cannot be added via this parameter. :param id: The id to attribute to this parameter set. + + .. versionadded: 8.4 + + :ref:`pytest.HIDDEN_PARAM` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. """ return ParameterSet.param(*values, marks=marks, id=id) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 70a4786b3f2..2af39a95071 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1209,8 +1209,12 @@ def parametrize( ``bool``, or ``None``. They are mapped to the corresponding index in ``argvalues``. ``None`` means to use the auto-generated id. - ``pytest.HIDDEN_PARAM`` means to hide the parameter set - from the test name. + + .. versionadded: 8.4 + + :ref:`pytest.HIDDEN_PARAM` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. If it is a callable it will be called for each entry in ``argvalues``, and the return value is used as part of the From f9af520093d1e1f79f6b88ca34ee870eb5ecf655 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Thu, 20 Feb 2025 21:40:11 +0300 Subject: [PATCH 06/11] Fix docs build --- changelog/13228.feature.rst | 2 +- doc/en/reference/reference.rst | 2 +- src/_pytest/mark/__init__.py | 3 ++- src/_pytest/python.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog/13228.feature.rst b/changelog/13228.feature.rst index fd65f2f2fc9..c5d84182313 100644 --- a/changelog/13228.feature.rst +++ b/changelog/13228.feature.rst @@ -1,3 +1,3 @@ -:ref:`pytest.HIDDEN_PARAM` can now be used in ``id`` of :func:`pytest.param` or in +:ref:`hidden-param` can now be used in ``id`` of :func:`pytest.param` or in ``ids`` of :py:func:`Metafunc.parametrize `. It hides the parameter set from the test name. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index e77e9a54d14..267ab37b1b1 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -20,7 +20,7 @@ The current pytest version, as a string:: >>> pytest.__version__ '7.0.0' -.. _`pytest.HIDDEN_PARAM ref`: +.. _`hidden-param`: pytest.HIDDEN_PARAM ~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 0e5747d22ba..e234a7d40ee 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -75,11 +75,12 @@ def test_eval(test_input, expected): :ref:`pytest.mark.usefixtures ` cannot be added via this parameter. + :type id: str | Literal[pytest.HIDDEN_PARAM] | None :param id: The id to attribute to this parameter set. .. versionadded: 8.4 - :ref:`pytest.HIDDEN_PARAM` means to hide the parameter set + :ref:`hidden-param` means to hide the parameter set from the test name. Can only be used at most 1 time, as test names need to be unique. """ diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 2af39a95071..63197960791 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1212,7 +1212,7 @@ def parametrize( .. versionadded: 8.4 - :ref:`pytest.HIDDEN_PARAM` means to hide the parameter set + :ref:`hidden-param` means to hide the parameter set from the test name. Can only be used at most 1 time, as test names need to be unique. From b77e633cd0e776faa422411c2a7de70d54cebb0a Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Thu, 20 Feb 2025 21:52:12 +0300 Subject: [PATCH 07/11] Fix docs --- src/_pytest/mark/__init__.py | 5 ++--- src/_pytest/python.py | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index e234a7d40ee..db19dfb9ebe 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -76,9 +76,8 @@ def test_eval(test_input, expected): :ref:`pytest.mark.usefixtures ` cannot be added via this parameter. :type id: str | Literal[pytest.HIDDEN_PARAM] | None - :param id: The id to attribute to this parameter set. - - .. versionadded: 8.4 + :param id: + The id to attribute to this parameter set. :ref:`hidden-param` means to hide the parameter set from the test name. Can only be used at most 1 time, as diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 63197960791..7cba342a931 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1210,8 +1210,6 @@ def parametrize( They are mapped to the corresponding index in ``argvalues``. ``None`` means to use the auto-generated id. - .. versionadded: 8.4 - :ref:`hidden-param` means to hide the parameter set from the test name. Can only be used at most 1 time, as test names need to be unique. From 406a84b0f3cd0fbc0758747ff5c5819799fbf6b0 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Thu, 20 Feb 2025 22:04:31 +0300 Subject: [PATCH 08/11] Fix docs --- src/_pytest/mark/__init__.py | 7 ++++--- src/_pytest/python.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index db19dfb9ebe..068c7410a46 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -79,9 +79,10 @@ def test_eval(test_input, expected): :param id: The id to attribute to this parameter set. - :ref:`hidden-param` means to hide the parameter set - from the test name. Can only be used at most 1 time, as - test names need to be unique. + .. versionadded:: 8.4 + :ref:`hidden-param` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. """ return ParameterSet.param(*values, marks=marks, id=id) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 7cba342a931..a65781e1408 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1210,9 +1210,10 @@ def parametrize( They are mapped to the corresponding index in ``argvalues``. ``None`` means to use the auto-generated id. - :ref:`hidden-param` means to hide the parameter set - from the test name. Can only be used at most 1 time, as - test names need to be unique. + .. versionadded:: 8.4 + :ref:`hidden-param` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. If it is a callable it will be called for each entry in ``argvalues``, and the return value is used as part of the From ae56b0764dd8f91e080414ca7c429d7b6da2cdc6 Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 24 Feb 2025 17:43:06 +0300 Subject: [PATCH 09/11] Fix review issues --- src/_pytest/mark/structures.py | 20 ++++++++++---------- src/_pytest/python.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 2fdb5988d52..b4e62563b84 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -39,6 +39,16 @@ EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" +# Singleton type for HIDDEN_PARAM, as described in: +# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions +class _HiddenParam(enum.Enum): + token = 0 + + +#: Can be used as a parameter set id to hide it from the test name. +HIDDEN_PARAM = _HiddenParam.token + + def istestfunc(func) -> bool: return callable(func) and getattr(func, "__name__", "") != "" @@ -66,16 +76,6 @@ def get_empty_parameterset_mark( return mark -# Singleton type for HIDDEN_PARAM, as described in: -# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions -class _HiddenParam(enum.Enum): - token = 0 - - -#: Can be used as a parameter set id to hide it from the test name. -HIDDEN_PARAM = _HiddenParam.token - - class ParameterSet(NamedTuple): values: Sequence[object | NotSetType] marks: Collection[MarkDecorator | Mark] diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a65781e1408..902bcfade9f 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -57,6 +57,7 @@ from _pytest.fixtures import get_scope_node from _pytest.main import Session from _pytest.mark import ParameterSet +from _pytest.mark.structures import _HiddenParam from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import HIDDEN_PARAM from _pytest.mark.structures import Mark @@ -886,7 +887,7 @@ class IdMaker: # Used only for clearer error messages. func_name: str | None - def make_unique_parameterset_ids(self) -> list[str | None]: + def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: """Make a unique identifier for each ParameterSet, that may be used to identify the parametrization in a node ID. @@ -907,7 +908,7 @@ def make_unique_parameterset_ids(self) -> list[str | None]: # Suffix non-unique IDs to make them unique. for index, id in enumerate(resolved_ids): if id_counts[id] > 1: - if id is None: + if id is HIDDEN_PARAM: self._complain_multiple_hidden_parameter_sets() suffix = "" if id and id[-1].isdigit(): @@ -923,19 +924,19 @@ def make_unique_parameterset_ids(self) -> list[str | None]: ) return resolved_ids - def _resolve_ids(self) -> Iterable[str | None]: + def _resolve_ids(self) -> Iterable[str | _HiddenParam]: """Resolve IDs for all ParameterSets (may contain duplicates).""" for idx, parameterset in enumerate(self.parametersets): if parameterset.id is not None: # ID provided directly - pytest.param(..., id="...") if parameterset.id is HIDDEN_PARAM: - yield None + yield HIDDEN_PARAM else: yield _ascii_escaped_by_config(parameterset.id, self.config) elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: # ID provided in the IDs list - parametrize(..., ids=[...]). if self.ids[idx] is HIDDEN_PARAM: - yield None + yield HIDDEN_PARAM else: yield self._idval_from_value_required(self.ids[idx], idx) else: @@ -1067,7 +1068,7 @@ def setmulti( *, argnames: Iterable[str], valset: Iterable[object], - id: str | None, + id: str | _HiddenParam, marks: Iterable[Mark | MarkDecorator], scope: Scope, param_index: int, @@ -1085,7 +1086,7 @@ def setmulti( params=params, indices=indices, _arg2scope=arg2scope, - _idlist=[*self._idlist, id] if id is not None else self._idlist, + _idlist=self._idlist if id is HIDDEN_PARAM else [*self._idlist, id], marks=[*self.marks, *normalize_mark_list(marks)], ) @@ -1347,7 +1348,7 @@ def _resolve_parameter_set_ids( ids: Iterable[object | None] | Callable[[Any], object | None] | None, parametersets: Sequence[ParameterSet], nodeid: str, - ) -> list[str | None]: + ) -> list[str | _HiddenParam]: """Resolve the actual ids for the given parameter sets. :param argnames: From 13a1455a44a3129f56f1dc2f0286cae4b67ccd4b Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 24 Feb 2025 19:20:33 +0300 Subject: [PATCH 10/11] Reflect new reality in pytest.param error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) --- src/_pytest/mark/structures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index b4e62563b84..a3290aed82e 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -100,7 +100,10 @@ def param( if id is not None: if not isinstance(id, str) and id is not HIDDEN_PARAM: - raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}") + raise TypeError( + "Expected id to be a string or a `pytest.HIDDEN_PARAM` sentinel, " + f"got {type(id)}: {id!r}", + ) return cls(values, marks, id) @classmethod From 11c8389a8104c14f3bee1dcaf75da879c4544e8b Mon Sep 17 00:00:00 2001 From: Anton Zhilin Date: Mon, 24 Feb 2025 19:41:30 +0300 Subject: [PATCH 11/11] Fix a broken test --- testing/test_mark.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testing/test_mark.py b/testing/test_mark.py index 7b76acf9990..1e51f9db18f 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1170,7 +1170,11 @@ def test_pytest_param_id_requires_string() -> None: with pytest.raises(TypeError) as excinfo: pytest.param(id=True) # type: ignore[arg-type] (msg,) = excinfo.value.args - assert msg == "Expected id to be a string, got : True" + expected = ( + "Expected id to be a string or a `pytest.HIDDEN_PARAM` sentinel, " + "got : True" + ) + assert msg == expected @pytest.mark.parametrize("s", (None, "hello world"))