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 000000000..d7bb1083a --- /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 be03fb2a8..fba9085d0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -939,10 +939,7 @@ class FixtureDef(Generic[FixtureValue]): 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 23baa9a61..cd951939e 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 @@ class IdMaker: 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 @@ class IdMaker: 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 @@ class Metafunc: 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 @@ class Metafunc: 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 @@ class Metafunc: 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 @@ class Metafunc: 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 b6ad4a809..2fed22718 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -106,8 +106,8 @@ class TestMetafunc: 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 TestMetafunc: 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 @@ class TestMetafunc: ] 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 @@ class TestMetafunc: ("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 @@ class TestMetafunc: ] 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 @@ class TestMetafunc: 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 @@ class TestMetafunc: 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 @@ class TestMetafunc: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["string-1.0", "st-ring-2.0"] @@ -397,17 +403,18 @@ class TestMetafunc: 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 @@ class TestMetafunc: None, None, None, + None, ).make_unique_parameterset_ids() assert result == [ "1.0--1.1", @@ -465,6 +473,7 @@ class TestMetafunc: 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 @@ class TestMetafunc: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["hello \\x00", "hello \\x05"] @@ -486,7 +496,7 @@ class TestMetafunc: 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 @@ class TestMetafunc: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["10.0-IndexError()", "20-KeyError()", "three-b2"] @@ -529,6 +540,7 @@ class TestMetafunc: None, None, None, + None, ).make_unique_parameterset_ids() assert result == ["a-a0", "a-a1", "a-a2"] @@ -560,7 +572,13 @@ class TestMetafunc: ] 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 @@ class TestMetafunc: ] 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 @@ class TestMetafunc: ["a", None], None, None, + None, ).make_unique_parameterset_ids() assert result == ["a", "3-4"] @@ -668,6 +687,7 @@ class TestMetafunc: ["a", None], None, None, + None, ).make_unique_parameterset_ids() assert result == ["me", "you"] @@ -679,6 +699,7 @@ class TestMetafunc: ["a", "a", "b", "c", "b"], None, None, + None, ).make_unique_parameterset_ids() assert result == ["a0", "a1", "b0", "c", "b1"] @@ -1318,7 +1339,7 @@ class TestMetafuncFunctional: """ 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 @@ class TestMetafuncFunctional: 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__." ] )