From c3aa4647c742107b2b1acf41c24b29e8c4bc8f99 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 5 Feb 2022 12:25:48 +0200 Subject: [PATCH] python: unify code to generate ID from value In the following @pytest.mark.parametrize(..., ids=[val]) the ID values are only allowed to be `str`, `float`, `int` or `bool`. In the following @pytest.mark.parametrize(..., [val]) @pytest.mark.parametrize(..., [pytest.param(..., id=val]) a different code path is used, which also allows `bytes`, `complex`, `re.Pattern`, `Enum` and anything with a `__name__`. In the interest of consistency, use the latter code path for all cases. --- changelog/9678.improvement.rst | 3 ++ src/_pytest/fixtures.py | 41 +++++---------------- src/_pytest/python.py | 67 ++++++++++++++++++---------------- testing/python/metafunc.py | 53 +++++++++++++++++++-------- 4 files changed, 85 insertions(+), 79 deletions(-) create mode 100644 changelog/9678.improvement.rst diff --git a/changelog/9678.improvement.rst b/changelog/9678.improvement.rst new file mode 100644 index 00000000000..d7bb1083a4d --- /dev/null +++ b/changelog/9678.improvement.rst @@ -0,0 +1,3 @@ +More types are now accepted in the ``ids`` argument to ``@pytest.mark.parametrize``. +Previously only `str`, `float`, `int` and `bool` were accepted; +now `bytes`, `complex`, `re.Pattern`, `Enum` and anything with a `__name__` are also accepted. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index be03fb2a820..fba9085d0c1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -939,10 +939,7 @@ def __init__( params: Optional[Sequence[object]], unittest: bool = False, ids: Optional[ - Union[ - Tuple[Union[None, str, float, int, bool], ...], - Callable[[Any], Optional[object]], - ] + Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] ] = None, ) -> None: self._fixturemanager = fixturemanager @@ -1093,18 +1090,8 @@ def pytest_fixture_setup( def _ensure_immutable_ids( - ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] - ], -) -> Optional[ - Union[ - Tuple[Union[None, str, float, int, bool], ...], - Callable[[Any], Optional[object]], - ] -]: + ids: Optional[Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]] +) -> Optional[Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]]: if ids is None: return None if callable(ids): @@ -1148,9 +1135,8 @@ class FixtureFunctionMarker: scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" params: Optional[Tuple[object, ...]] = attr.ib(converter=_params_converter) autouse: bool = False - ids: Union[ - Tuple[Union[None, str, float, int, bool], ...], - Callable[[Any], Optional[object]], + ids: Optional[ + Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] ] = attr.ib( default=None, converter=_ensure_immutable_ids, @@ -1191,10 +1177,7 @@ def fixture( params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] + Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = ..., name: Optional[str] = ..., ) -> FixtureFunction: @@ -1209,10 +1192,7 @@ def fixture( params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] + Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = ..., name: Optional[str] = None, ) -> FixtureFunctionMarker: @@ -1226,10 +1206,7 @@ def fixture( params: Optional[Iterable[object]] = None, autouse: bool = False, ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] + Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = None, name: Optional[str] = None, ) -> Union[FixtureFunctionMarker, FixtureFunction]: @@ -1271,7 +1248,7 @@ def fixture( the fixture. :param ids: - List of string ids each corresponding to the params so that they are + Sequence of ids each corresponding to the params so that they are part of the test id. If no ids are provided they will be generated automatically from the params. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 23baa9a61f2..cd951939e7a 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -940,7 +940,7 @@ class IdMaker: # ParameterSet. idfn: Optional[Callable[[Any], Optional[object]]] # Optionally, explicit IDs for ParameterSets by index. - ids: Optional[Sequence[Union[None, str]]] + ids: Optional[Sequence[Optional[object]]] # Optionally, the pytest config. # Used for controlling ASCII escaping, and for calling the # :hook:`pytest_make_parametrize_id` hook. @@ -948,6 +948,9 @@ class IdMaker: # Optionally, the ID of the node being parametrized. # Used only for clearer error messages. nodeid: Optional[str] + # Optionally, the ID of the function being parametrized. + # Used only for clearer error messages. + func_name: Optional[str] def make_unique_parameterset_ids(self) -> List[str]: """Make a unique identifier for each ParameterSet, that may be used to @@ -982,9 +985,7 @@ def _resolve_ids(self) -> Iterable[str]: yield parameterset.id elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: # ID provided in the IDs list - parametrize(..., ids=[...]). - id = self.ids[idx] - assert id is not None - yield _ascii_escaped_by_config(id, self.config) + yield self._idval_from_value_required(self.ids[idx], idx) else: # ID not provided - generate it. yield "-".join( @@ -1053,6 +1054,25 @@ def _idval_from_value(self, val: object) -> Optional[str]: return name return None + def _idval_from_value_required(self, val: object, idx: int) -> str: + """Like _idval_from_value(), but fails if the type is not supported.""" + id = self._idval_from_value(val) + if id is not None: + 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 = "" + 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__." + ) + fail(msg, pytrace=False) + @staticmethod def _idval_from_argname(argname: str, idx: int) -> str: """Make an ID for a parameter in a ParameterSet from the argument name @@ -1182,10 +1202,7 @@ def parametrize( argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], indirect: Union[bool, Sequence[str]] = False, ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] + Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] ] = None, scope: "Optional[_ScopeName]" = None, *, @@ -1316,10 +1333,7 @@ def _resolve_parameter_set_ids( self, argnames: Sequence[str], ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] + Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] ], parametersets: Sequence[ParameterSet], nodeid: str, @@ -1349,16 +1363,22 @@ def _resolve_parameter_set_ids( idfn = None ids_ = self._validate_ids(ids, parametersets, self.function.__name__) id_maker = IdMaker( - argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid + argnames, + parametersets, + idfn, + ids_, + self.config, + nodeid=nodeid, + func_name=self.function.__name__, ) return id_maker.make_unique_parameterset_ids() def _validate_ids( self, - ids: Iterable[Union[None, str, float, int, bool]], + ids: Iterable[Optional[object]], parametersets: Sequence[ParameterSet], func_name: str, - ) -> List[Union[None, str]]: + ) -> List[Optional[object]]: try: num_ids = len(ids) # type: ignore[arg-type] except TypeError: @@ -1373,22 +1393,7 @@ def _validate_ids( msg = "In {}: {} parameter sets specified, with different number of ids: {}" fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False) - new_ids = [] - for idx, id_value in enumerate(itertools.islice(ids, num_ids)): - if id_value is None or isinstance(id_value, str): - new_ids.append(id_value) - elif isinstance(id_value, (float, int, bool)): - new_ids.append(str(id_value)) - else: - msg = ( # type: ignore[unreachable] - "In {}: ids must be list of string/float/int/bool, " - "found: {} (type: {!r}) at index {}" - ) - fail( - msg.format(func_name, saferepr(id_value), type(id_value), idx), - pytrace=False, - ) - return new_ids + return list(itertools.islice(ids, num_ids)) def _resolve_arg_value_types( self, diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index b6ad4a80924..2fed22718b0 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -106,8 +106,8 @@ def gen() -> Iterator[Union[int, None, Exc]]: with pytest.raises( fail.Exception, match=( - r"In func: ids must be list of string/float/int/bool, found:" - r" Exc\(from_gen\) \(type: \) at index 2" + r"In func: ids contains unsupported value Exc\(from_gen\) \(type: \) at index 2. " + r"Supported types are: .*" ), ): metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type] @@ -285,7 +285,7 @@ class A: deadline=400.0 ) # very close to std deadline and CI boxes are not reliable in CPU power def test_idval_hypothesis(self, value) -> None: - escaped = IdMaker([], [], None, None, None, None)._idval(value, "a", 6) + escaped = IdMaker([], [], None, None, None, None, None)._idval(value, "a", 6) assert isinstance(escaped, str) escaped.encode("ascii") @@ -308,7 +308,8 @@ def test_unicode_idval(self) -> None: ] for val, expected in values: assert ( - IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + IdMaker([], [], None, None, None, None, None)._idval(val, "a", 6) + == expected ) def test_unicode_idval_with_config(self) -> None: @@ -337,7 +338,7 @@ def getini(self, name): ("ação", MockConfig({option: False}), "a\\xe7\\xe3o"), ] for val, config, expected in values: - actual = IdMaker([], [], None, None, config, None)._idval(val, "a", 6) + actual = IdMaker([], [], None, None, config, None, None)._idval(val, "a", 6) assert actual == expected def test_bytes_idval(self) -> None: @@ -351,7 +352,8 @@ def test_bytes_idval(self) -> None: ] for val, expected in values: assert ( - IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + IdMaker([], [], None, None, None, None, None)._idval(val, "a", 6) + == expected ) def test_class_or_function_idval(self) -> None: @@ -367,7 +369,8 @@ def test_function(): values = [(TestClass, "TestClass"), (test_function, "test_function")] for val, expected in values: assert ( - IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + IdMaker([], [], None, None, None, None, None)._idval(val, "a", 6) + == expected ) def test_notset_idval(self) -> None: @@ -376,7 +379,9 @@ def test_notset_idval(self) -> None: Regression test for #7686. """ - assert IdMaker([], [], None, None, None, None)._idval(NOTSET, "a", 0) == "a0" + assert ( + IdMaker([], [], None, None, None, None, None)._idval(NOTSET, "a", 0) == "a0" + ) def test_idmaker_autoname(self) -> None: """#250""" @@ -387,6 +392,7 @@ def test_idmaker_autoname(self) -> None: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["string-1.0", "st-ring-2.0"] @@ -397,17 +403,18 @@ def test_idmaker_autoname(self) -> None: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["a0-1.0", "a1-b1"] # unicode mixing, issue250 result = IdMaker( - ("a", "b"), [pytest.param({}, b"\xc3\xb4")], None, None, None, None + ("a", "b"), [pytest.param({}, b"\xc3\xb4")], None, None, None, None, None ).make_unique_parameterset_ids() assert result == ["a0-\\xc3\\xb4"] def test_idmaker_with_bytes_regex(self) -> None: result = IdMaker( - ("a"), [pytest.param(re.compile(b"foo"), 1.0)], None, None, None, None + ("a"), [pytest.param(re.compile(b"foo"), 1.0)], None, None, None, None, None ).make_unique_parameterset_ids() assert result == ["foo"] @@ -433,6 +440,7 @@ def test_idmaker_native_strings(self) -> None: None, None, None, + None, ).make_unique_parameterset_ids() assert result == [ "1.0--1.1", @@ -465,6 +473,7 @@ def test_idmaker_non_printable_characters(self) -> None: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["\\x00-1", "\\x05-2", "\\x00-3", "\\x05-4", "\\t-5", "\\t-6"] @@ -479,6 +488,7 @@ def test_idmaker_manual_ids_must_be_printable(self) -> None: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["hello \\x00", "hello \\x05"] @@ -486,7 +496,7 @@ def test_idmaker_enum(self) -> None: enum = pytest.importorskip("enum") e = enum.Enum("Foo", "one, two") result = IdMaker( - ("a", "b"), [pytest.param(e.one, e.two)], None, None, None, None + ("a", "b"), [pytest.param(e.one, e.two)], None, None, None, None, None ).make_unique_parameterset_ids() assert result == ["Foo.one-Foo.two"] @@ -509,6 +519,7 @@ def ids(val: object) -> Optional[str]: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["10.0-IndexError()", "20-KeyError()", "three-b2"] @@ -529,6 +540,7 @@ def ids(val: object) -> str: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["a-a0", "a-a1", "a-a2"] @@ -560,7 +572,13 @@ def getini(self, name): ] for config, expected in values: result = IdMaker( - ("a",), [pytest.param("string")], lambda _: "ação", None, config, None + ("a",), + [pytest.param("string")], + lambda _: "ação", + None, + config, + None, + None, ).make_unique_parameterset_ids() assert result == [expected] @@ -592,7 +610,7 @@ def getini(self, name): ] for config, expected in values: result = IdMaker( - ("a",), [pytest.param("string")], None, ["ação"], config, None + ("a",), [pytest.param("string")], None, ["ação"], config, None, None ).make_unique_parameterset_ids() assert result == [expected] @@ -657,6 +675,7 @@ def test_idmaker_with_ids(self) -> None: ["a", None], None, None, + None, ).make_unique_parameterset_ids() assert result == ["a", "3-4"] @@ -668,6 +687,7 @@ def test_idmaker_with_paramset_id(self) -> None: ["a", None], None, None, + None, ).make_unique_parameterset_ids() assert result == ["me", "you"] @@ -679,6 +699,7 @@ def test_idmaker_with_ids_unique_names(self) -> None: ["a", "a", "b", "c", "b"], None, None, + None, ).make_unique_parameterset_ids() assert result == ["a0", "a1", "b0", "c", "b1"] @@ -1318,7 +1339,7 @@ def test_parametrized_ids_invalid_type(self, pytester: Pytester) -> None: """ import pytest - @pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type)) + @pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, OSError())) def test_ids_numbers(x,expected): assert x * 2 == expected """ @@ -1326,8 +1347,8 @@ def test_ids_numbers(x,expected): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "In test_ids_numbers: ids must be list of string/float/int/bool," - " found: (type: ) at index 2" + "In test_ids_numbers: ids contains unsupported value OSError() (type: ) at index 2. " + "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__." ] )