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..c5d84182313 --- /dev/null +++ b/changelog/13228.feature.rst @@ -0,0 +1,3 @@ +: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 809e97b4747..267ab37b1b1 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' +.. _`hidden-param`: + +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 488b562a298..068c7410a46 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -12,8 +12,10 @@ 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 from .structures import Mark from .structures import MARK_GEN from .structures import MarkDecorator @@ -33,6 +35,7 @@ __all__ = [ + "HIDDEN_PARAM", "MARK_GEN", "Mark", "MarkDecorator", @@ -48,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 `. @@ -72,7 +75,14 @@ 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. + :type id: str | Literal[pytest.HIDDEN_PARAM] | None + :param id: + The id to attribute to this parameter set. + + .. 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/mark/structures.py b/src/_pytest/mark/structures.py index 1a0b3c5b5b8..a3290aed82e 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 @@ -38,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__", "") != "" @@ -68,14 +79,14 @@ def get_empty_parameterset_mark( 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,8 +99,11 @@ def param( ) if id is not None: - if not isinstance(id, str): - raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}") + if not isinstance(id, str) and id is not HIDDEN_PARAM: + 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 diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ef8a5f02b53..902bcfade9f 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 @@ -56,7 +57,9 @@ 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 from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list @@ -473,7 +476,7 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: - subname = f"{name}[{callspec.id}]" + subname = f"{name}[{callspec.id}]" if callspec._idlist else name yield Function.from_parent( self, name=subname, @@ -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 | _HiddenParam]: """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 HIDDEN_PARAM: + 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 | _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="...") - yield _ascii_escaped_by_config(parameterset.id, self.config) + if parameterset.id is HIDDEN_PARAM: + 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=[...]). - yield self._idval_from_value_required(self.ids[idx], idx) + if self.ids[idx] is HIDDEN_PARAM: + yield HIDDEN_PARAM + 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 | _HiddenParam, 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 if id is HIDDEN_PARAM else [*self._idlist, id], marks=[*self.marks, *normalize_mark_list(marks)], ) @@ -1190,6 +1211,11 @@ 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. + If it is a callable it will be called for each entry in ``argvalues``, and the return value is used as part of the auto-generated id for the whole set (where parts are joined with @@ -1322,7 +1348,7 @@ def _resolve_parameter_set_ids( ids: Iterable[object | None] | Callable[[Any], object | None] | None, parametersets: Sequence[ParameterSet], nodeid: str, - ) -> list[str]: + ) -> list[str | _HiddenParam]: """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..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 @@ -2143,3 +2144,127 @@ 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: + items = pytester.getitems( + """ + 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 + """ + ) + 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: + items = pytester.getitems( + """ + 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 + """ + ) + 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( + """ + 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_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( + """ + 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 + """ + ) + names = [item.name for item in items] + assert names == [ + "test_func[a-x]", + "test_func[a-y]", + "test_func[x]", + "test_func[y]", + ] 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"))