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.
This commit is contained in:
Ran Benita 2022-02-05 12:25:48 +02:00
parent c01a5c177b
commit c3aa4647c7
4 changed files with 85 additions and 79 deletions

View File

@ -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.

View File

@ -939,10 +939,7 @@ class FixtureDef(Generic[FixtureValue]):
params: Optional[Sequence[object]], params: Optional[Sequence[object]],
unittest: bool = False, unittest: bool = False,
ids: Optional[ ids: Optional[
Union[ Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]
Tuple[Union[None, str, float, int, bool], ...],
Callable[[Any], Optional[object]],
]
] = None, ] = None,
) -> None: ) -> None:
self._fixturemanager = fixturemanager self._fixturemanager = fixturemanager
@ -1093,18 +1090,8 @@ def pytest_fixture_setup(
def _ensure_immutable_ids( def _ensure_immutable_ids(
ids: Optional[ ids: Optional[Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]]
Union[ ) -> Optional[Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]]:
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
],
) -> Optional[
Union[
Tuple[Union[None, str, float, int, bool], ...],
Callable[[Any], Optional[object]],
]
]:
if ids is None: if ids is None:
return None return None
if callable(ids): if callable(ids):
@ -1148,9 +1135,8 @@ class FixtureFunctionMarker:
scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]"
params: Optional[Tuple[object, ...]] = attr.ib(converter=_params_converter) params: Optional[Tuple[object, ...]] = attr.ib(converter=_params_converter)
autouse: bool = False autouse: bool = False
ids: Union[ ids: Optional[
Tuple[Union[None, str, float, int, bool], ...], Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]
Callable[[Any], Optional[object]],
] = attr.ib( ] = attr.ib(
default=None, default=None,
converter=_ensure_immutable_ids, converter=_ensure_immutable_ids,
@ -1191,10 +1177,7 @@ def fixture(
params: Optional[Iterable[object]] = ..., params: Optional[Iterable[object]] = ...,
autouse: bool = ..., autouse: bool = ...,
ids: Optional[ ids: Optional[
Union[ Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
] = ..., ] = ...,
name: Optional[str] = ..., name: Optional[str] = ...,
) -> FixtureFunction: ) -> FixtureFunction:
@ -1209,10 +1192,7 @@ def fixture(
params: Optional[Iterable[object]] = ..., params: Optional[Iterable[object]] = ...,
autouse: bool = ..., autouse: bool = ...,
ids: Optional[ ids: Optional[
Union[ Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
] = ..., ] = ...,
name: Optional[str] = None, name: Optional[str] = None,
) -> FixtureFunctionMarker: ) -> FixtureFunctionMarker:
@ -1226,10 +1206,7 @@ def fixture(
params: Optional[Iterable[object]] = None, params: Optional[Iterable[object]] = None,
autouse: bool = False, autouse: bool = False,
ids: Optional[ ids: Optional[
Union[ Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
] = None, ] = None,
name: Optional[str] = None, name: Optional[str] = None,
) -> Union[FixtureFunctionMarker, FixtureFunction]: ) -> Union[FixtureFunctionMarker, FixtureFunction]:
@ -1271,7 +1248,7 @@ def fixture(
the fixture. the fixture.
:param ids: :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 part of the test id. If no ids are provided they will be generated
automatically from the params. automatically from the params.

View File

@ -940,7 +940,7 @@ class IdMaker:
# ParameterSet. # ParameterSet.
idfn: Optional[Callable[[Any], Optional[object]]] idfn: Optional[Callable[[Any], Optional[object]]]
# Optionally, explicit IDs for ParameterSets by index. # Optionally, explicit IDs for ParameterSets by index.
ids: Optional[Sequence[Union[None, str]]] ids: Optional[Sequence[Optional[object]]]
# Optionally, the pytest config. # Optionally, the pytest config.
# Used for controlling ASCII escaping, and for calling the # Used for controlling ASCII escaping, and for calling the
# :hook:`pytest_make_parametrize_id` hook. # :hook:`pytest_make_parametrize_id` hook.
@ -948,6 +948,9 @@ class IdMaker:
# Optionally, the ID of the node being parametrized. # Optionally, the ID of the node being parametrized.
# Used only for clearer error messages. # Used only for clearer error messages.
nodeid: Optional[str] 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]: def make_unique_parameterset_ids(self) -> List[str]:
"""Make a unique identifier for each ParameterSet, that may be used to """Make a unique identifier for each ParameterSet, that may be used to
@ -982,9 +985,7 @@ class IdMaker:
yield parameterset.id yield parameterset.id
elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: elif self.ids and idx < len(self.ids) and self.ids[idx] is not None:
# ID provided in the IDs list - parametrize(..., ids=[...]). # ID provided in the IDs list - parametrize(..., ids=[...]).
id = self.ids[idx] yield self._idval_from_value_required(self.ids[idx], idx)
assert id is not None
yield _ascii_escaped_by_config(id, self.config)
else: else:
# ID not provided - generate it. # ID not provided - generate it.
yield "-".join( yield "-".join(
@ -1053,6 +1054,25 @@ class IdMaker:
return name return name
return None 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 @staticmethod
def _idval_from_argname(argname: str, idx: int) -> str: def _idval_from_argname(argname: str, idx: int) -> str:
"""Make an ID for a parameter in a ParameterSet from the argument name """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]], argvalues: Iterable[Union[ParameterSet, Sequence[object], object]],
indirect: Union[bool, Sequence[str]] = False, indirect: Union[bool, Sequence[str]] = False,
ids: Optional[ ids: Optional[
Union[ Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]]
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
] = None, ] = None,
scope: "Optional[_ScopeName]" = None, scope: "Optional[_ScopeName]" = None,
*, *,
@ -1316,10 +1333,7 @@ class Metafunc:
self, self,
argnames: Sequence[str], argnames: Sequence[str],
ids: Optional[ ids: Optional[
Union[ Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]]
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
], ],
parametersets: Sequence[ParameterSet], parametersets: Sequence[ParameterSet],
nodeid: str, nodeid: str,
@ -1349,16 +1363,22 @@ class Metafunc:
idfn = None idfn = None
ids_ = self._validate_ids(ids, parametersets, self.function.__name__) ids_ = self._validate_ids(ids, parametersets, self.function.__name__)
id_maker = IdMaker( 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() return id_maker.make_unique_parameterset_ids()
def _validate_ids( def _validate_ids(
self, self,
ids: Iterable[Union[None, str, float, int, bool]], ids: Iterable[Optional[object]],
parametersets: Sequence[ParameterSet], parametersets: Sequence[ParameterSet],
func_name: str, func_name: str,
) -> List[Union[None, str]]: ) -> List[Optional[object]]:
try: try:
num_ids = len(ids) # type: ignore[arg-type] num_ids = len(ids) # type: ignore[arg-type]
except TypeError: except TypeError:
@ -1373,22 +1393,7 @@ class Metafunc:
msg = "In {}: {} parameter sets specified, with different number of ids: {}" msg = "In {}: {} parameter sets specified, with different number of ids: {}"
fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False) fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False)
new_ids = [] return list(itertools.islice(ids, num_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
def _resolve_arg_value_types( def _resolve_arg_value_types(
self, self,

View File

@ -106,8 +106,8 @@ class TestMetafunc:
with pytest.raises( with pytest.raises(
fail.Exception, fail.Exception,
match=( match=(
r"In func: ids must be list of string/float/int/bool, found:" r"In func: ids contains unsupported value Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2. "
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2" r"Supported types are: .*"
), ),
): ):
metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type] metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type]
@ -285,7 +285,7 @@ class TestMetafunc:
deadline=400.0 deadline=400.0
) # very close to std deadline and CI boxes are not reliable in CPU power ) # very close to std deadline and CI boxes are not reliable in CPU power
def test_idval_hypothesis(self, value) -> None: 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) assert isinstance(escaped, str)
escaped.encode("ascii") escaped.encode("ascii")
@ -308,7 +308,8 @@ class TestMetafunc:
] ]
for val, expected in values: for val, expected in values:
assert ( 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: def test_unicode_idval_with_config(self) -> None:
@ -337,7 +338,7 @@ class TestMetafunc:
("ação", MockConfig({option: False}), "a\\xe7\\xe3o"), ("ação", MockConfig({option: False}), "a\\xe7\\xe3o"),
] ]
for val, config, expected in values: 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 assert actual == expected
def test_bytes_idval(self) -> None: def test_bytes_idval(self) -> None:
@ -351,7 +352,8 @@ class TestMetafunc:
] ]
for val, expected in values: for val, expected in values:
assert ( 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: def test_class_or_function_idval(self) -> None:
@ -367,7 +369,8 @@ class TestMetafunc:
values = [(TestClass, "TestClass"), (test_function, "test_function")] values = [(TestClass, "TestClass"), (test_function, "test_function")]
for val, expected in values: for val, expected in values:
assert ( 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: def test_notset_idval(self) -> None:
@ -376,7 +379,9 @@ class TestMetafunc:
Regression test for #7686. 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: def test_idmaker_autoname(self) -> None:
"""#250""" """#250"""
@ -387,6 +392,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["string-1.0", "st-ring-2.0"] assert result == ["string-1.0", "st-ring-2.0"]
@ -397,17 +403,18 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["a0-1.0", "a1-b1"] assert result == ["a0-1.0", "a1-b1"]
# unicode mixing, issue250 # unicode mixing, issue250
result = IdMaker( 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() ).make_unique_parameterset_ids()
assert result == ["a0-\\xc3\\xb4"] assert result == ["a0-\\xc3\\xb4"]
def test_idmaker_with_bytes_regex(self) -> None: def test_idmaker_with_bytes_regex(self) -> None:
result = IdMaker( 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() ).make_unique_parameterset_ids()
assert result == ["foo"] assert result == ["foo"]
@ -433,6 +440,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == [ assert result == [
"1.0--1.1", "1.0--1.1",
@ -465,6 +473,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["\\x00-1", "\\x05-2", "\\x00-3", "\\x05-4", "\\t-5", "\\t-6"] assert result == ["\\x00-1", "\\x05-2", "\\x00-3", "\\x05-4", "\\t-5", "\\t-6"]
@ -479,6 +488,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["hello \\x00", "hello \\x05"] assert result == ["hello \\x00", "hello \\x05"]
@ -486,7 +496,7 @@ class TestMetafunc:
enum = pytest.importorskip("enum") enum = pytest.importorskip("enum")
e = enum.Enum("Foo", "one, two") e = enum.Enum("Foo", "one, two")
result = IdMaker( 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() ).make_unique_parameterset_ids()
assert result == ["Foo.one-Foo.two"] assert result == ["Foo.one-Foo.two"]
@ -509,6 +519,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["10.0-IndexError()", "20-KeyError()", "three-b2"] assert result == ["10.0-IndexError()", "20-KeyError()", "three-b2"]
@ -529,6 +540,7 @@ class TestMetafunc:
None, None,
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["a-a0", "a-a1", "a-a2"] assert result == ["a-a0", "a-a1", "a-a2"]
@ -560,7 +572,13 @@ class TestMetafunc:
] ]
for config, expected in values: for config, expected in values:
result = IdMaker( 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() ).make_unique_parameterset_ids()
assert result == [expected] assert result == [expected]
@ -592,7 +610,7 @@ class TestMetafunc:
] ]
for config, expected in values: for config, expected in values:
result = IdMaker( 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() ).make_unique_parameterset_ids()
assert result == [expected] assert result == [expected]
@ -657,6 +675,7 @@ class TestMetafunc:
["a", None], ["a", None],
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["a", "3-4"] assert result == ["a", "3-4"]
@ -668,6 +687,7 @@ class TestMetafunc:
["a", None], ["a", None],
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["me", "you"] assert result == ["me", "you"]
@ -679,6 +699,7 @@ class TestMetafunc:
["a", "a", "b", "c", "b"], ["a", "a", "b", "c", "b"],
None, None,
None, None,
None,
).make_unique_parameterset_ids() ).make_unique_parameterset_ids()
assert result == ["a0", "a1", "b0", "c", "b1"] assert result == ["a0", "a1", "b0", "c", "b1"]
@ -1318,7 +1339,7 @@ class TestMetafuncFunctional:
""" """
import pytest 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): def test_ids_numbers(x,expected):
assert x * 2 == expected assert x * 2 == expected
""" """
@ -1326,8 +1347,8 @@ class TestMetafuncFunctional:
result = pytester.runpytest() result = pytester.runpytest()
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
[ [
"In test_ids_numbers: ids must be list of string/float/int/bool," "In test_ids_numbers: ids contains unsupported value OSError() (type: <class 'OSError'>) at index 2. "
" found: <class 'type'> (type: <class 'type'>) at index 2" "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__."
] ]
) )