diff --git a/src/_pytest/python.py b/src/_pytest/python.py index dc8b1d9a712..f5b332e6831 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -929,6 +929,139 @@ def hasnew(obj: object) -> bool: return False +@final +@attr.s(frozen=True, auto_attribs=True, slots=True) +class IdMaker: + """Make IDs for a parametrization.""" + + # The argnames of the parametrization. + argnames: Sequence[str] + # The ParameterSets of the parametrization. + parametersets: Sequence[ParameterSet] + # Optionally, a user-provided callable to make IDs for parameters in a + # ParameterSet. + idfn: Optional[Callable[[Any], Optional[object]]] + # Optionally, explicit IDs for ParameterSets by index. + ids: Optional[Sequence[Union[None, str]]] + # Optionally, the pytest config. + # Used for controlling ASCII escaping, and for calling the + # :hook:`pytest_make_parametrize_id` hook. + config: Optional[Config] + # Optionally, the ID of the node being parametrized. + # Used only for clearer error messages. + nodeid: Optional[str] + + def make_unique_parameterset_ids(self) -> List[str]: + """Make a unique identifier for each ParameterSet, that may be used to + identify the parametrization in a node ID. + + Format is -...-[counter], where prm_x_token is + - user-provided id, if given + - else an id derived from the value, applicable for certain types + - else + The counter suffix is appended only in case a string wouldn't be unique + otherwise. + """ + resolved_ids = list(self._resolve_ids()) + # All IDs must be unique! + if len(resolved_ids) != len(set(resolved_ids)): + # Record the number of occurrences of each ID. + id_counts = Counter(resolved_ids) + # Map the ID to its next suffix. + id_suffixes: Dict[str, int] = defaultdict(int) + # Suffix non-unique IDs to make them unique. + for index, id in enumerate(resolved_ids): + if id_counts[id] > 1: + resolved_ids[index] = f"{id}{id_suffixes[id]}" + id_suffixes[id] += 1 + return resolved_ids + + def _resolve_ids(self) -> Iterable[str]: + """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 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) + else: + # ID not provided - generate it. + yield "-".join( + self._idval(val, argname, idx) + for val, argname in zip(parameterset.values, self.argnames) + ) + + def _idval(self, val: object, argname: str, idx: int) -> str: + """Make an ID for a parameter in a ParameterSet.""" + idval = self._idval_from_function(val, argname, idx) + if idval is not None: + return idval + idval = self._idval_from_hook(val, argname) + if idval is not None: + return idval + idval = self._idval_from_value(val) + if idval is not None: + return idval + return self._idval_from_argname(argname, idx) + + def _idval_from_function( + self, val: object, argname: str, idx: int + ) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet using the + user-provided id callable, if given.""" + if self.idfn is None: + return None + try: + id = self.idfn(val) + except Exception as e: + prefix = f"{self.nodeid}: " if self.nodeid is not None else "" + msg = "error raised while trying to determine id of parameter '{}' at position {}" + msg = prefix + msg.format(argname, idx) + raise ValueError(msg) from e + if id is None: + return None + return self._idval_from_value(id) + + def _idval_from_hook(self, val: object, argname: str) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet by calling the + :hook:`pytest_make_parametrize_id` hook.""" + if self.config: + id: Optional[str] = self.config.hook.pytest_make_parametrize_id( + config=self.config, val=val, argname=argname + ) + return id + return None + + def _idval_from_value(self, val: object) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet from its value, + if the value type is supported.""" + if isinstance(val, STRING_TYPES): + return _ascii_escaped_by_config(val, self.config) + elif val is None or isinstance(val, (float, int, bool, complex)): + return str(val) + elif isinstance(val, Pattern): + return ascii_escaped(val.pattern) + elif val is NOTSET: + # Fallback to default. Note that NOTSET is an enum.Enum. + pass + elif isinstance(val, enum.Enum): + return str(val) + elif isinstance(getattr(val, "__name__", None), str): + # Name of a class, function, module, etc. + name: str = getattr(val, "__name__") + return name + return None + + @staticmethod + def _idval_from_argname(argname: str, idx: int) -> str: + """Make an ID for a parameter in a ParameterSet from the argument name + and the index of the ParameterSet.""" + return str(argname) + str(idx) + + @final @attr.s(frozen=True, slots=True, auto_attribs=True) class CallSpec2: @@ -1217,12 +1350,15 @@ def _resolve_parameter_set_ids( else: idfn = None ids_ = self._validate_ids(ids, parametersets, self.function.__name__) - return idmaker(argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid) + id_maker = IdMaker( + argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid + ) + return id_maker.make_unique_parameterset_ids() def _validate_ids( self, ids: Iterable[Union[None, str, float, int, bool]], - parameters: Sequence[ParameterSet], + parametersets: Sequence[ParameterSet], func_name: str, ) -> List[Union[None, str]]: try: @@ -1232,12 +1368,12 @@ def _validate_ids( iter(ids) except TypeError as e: raise TypeError("ids must be a callable or an iterable") from e - num_ids = len(parameters) + num_ids = len(parametersets) # num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849 - if num_ids != len(parameters) and num_ids != 0: + if num_ids != len(parametersets) and num_ids != 0: msg = "In {}: {} parameter sets specified, with different number of ids: {}" - fail(msg.format(func_name, len(parameters), num_ids), pytrace=False) + fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False) new_ids = [] for idx, id_value in enumerate(itertools.islice(ids, num_ids)): @@ -1374,105 +1510,6 @@ def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) - return val if escape_option else ascii_escaped(val) # type: ignore -def _idval( - val: object, - argname: str, - idx: int, - idfn: Optional[Callable[[Any], Optional[object]]], - nodeid: Optional[str], - config: Optional[Config], -) -> str: - if idfn: - try: - generated_id = idfn(val) - if generated_id is not None: - val = generated_id - except Exception as e: - prefix = f"{nodeid}: " if nodeid is not None else "" - msg = "error raised while trying to determine id of parameter '{}' at position {}" - msg = prefix + msg.format(argname, idx) - raise ValueError(msg) from e - elif config: - hook_id: Optional[str] = config.hook.pytest_make_parametrize_id( - config=config, val=val, argname=argname - ) - if hook_id: - return hook_id - - if isinstance(val, STRING_TYPES): - return _ascii_escaped_by_config(val, config) - elif val is None or isinstance(val, (float, int, bool, complex)): - return str(val) - elif isinstance(val, Pattern): - return ascii_escaped(val.pattern) - elif val is NOTSET: - # Fallback to default. Note that NOTSET is an enum.Enum. - pass - elif isinstance(val, enum.Enum): - return str(val) - elif isinstance(getattr(val, "__name__", None), str): - # Name of a class, function, module, etc. - name: str = getattr(val, "__name__") - return name - return str(argname) + str(idx) - - -def _idvalset( - idx: int, - parameterset: ParameterSet, - argnames: Iterable[str], - idfn: Optional[Callable[[Any], Optional[object]]], - ids: Optional[List[Union[None, str]]], - nodeid: Optional[str], - config: Optional[Config], -) -> str: - if parameterset.id is not None: - return parameterset.id - id = None if ids is None or idx >= len(ids) else ids[idx] - if id is None: - this_id = [ - _idval(val, argname, idx, idfn, nodeid=nodeid, config=config) - for val, argname in zip(parameterset.values, argnames) - ] - return "-".join(this_id) - else: - return _ascii_escaped_by_config(id, config) - - -def idmaker( - argnames: Iterable[str], - parametersets: Iterable[ParameterSet], - idfn: Optional[Callable[[Any], Optional[object]]] = None, - ids: Optional[List[Union[None, str]]] = None, - config: Optional[Config] = None, - nodeid: Optional[str] = None, -) -> List[str]: - resolved_ids = [ - _idvalset( - valindex, parameterset, argnames, idfn, ids, config=config, nodeid=nodeid - ) - for valindex, parameterset in enumerate(parametersets) - ] - - # All IDs must be unique! - unique_ids = set(resolved_ids) - if len(unique_ids) != len(resolved_ids): - - # Record the number of occurrences of each test ID. - test_id_counts = Counter(resolved_ids) - - # Map the test ID to its next suffix. - test_id_suffixes: Dict[str, int] = defaultdict(int) - - # Suffix non-unique IDs to make them unique. - for index, test_id in enumerate(resolved_ids): - if test_id_counts[test_id] > 1: - resolved_ids[index] = f"{test_id}{test_id_suffixes[test_id]}" - test_id_suffixes[test_id] += 1 - - return resolved_ids - - def _pretty_fixture_path(func) -> str: cwd = Path.cwd() loc = Path(getlocation(func, str(cwd))) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index fc0082eb6b9..b6ad4a80924 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -24,8 +24,7 @@ from _pytest.compat import NOTSET from _pytest.outcomes import fail from _pytest.pytester import Pytester -from _pytest.python import _idval -from _pytest.python import idmaker +from _pytest.python import IdMaker from _pytest.scope import Scope @@ -286,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 = _idval(value, "a", 6, None, nodeid=None, config=None) + escaped = IdMaker([], [], None, None, None, None)._idval(value, "a", 6) assert isinstance(escaped, str) escaped.encode("ascii") @@ -308,7 +307,9 @@ def test_unicode_idval(self) -> None: ), ] for val, expected in values: - assert _idval(val, "a", 6, None, nodeid=None, config=None) == expected + assert ( + IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + ) def test_unicode_idval_with_config(self) -> None: """Unit test for expected behavior to obtain ids with @@ -336,7 +337,7 @@ def getini(self, name): ("ação", MockConfig({option: False}), "a\\xe7\\xe3o"), ] for val, config, expected in values: - actual = _idval(val, "a", 6, None, nodeid=None, config=config) + actual = IdMaker([], [], None, None, config, None)._idval(val, "a", 6) assert actual == expected def test_bytes_idval(self) -> None: @@ -349,7 +350,9 @@ def test_bytes_idval(self) -> None: ("αρά".encode(), r"\xce\xb1\xcf\x81\xce\xac"), ] for val, expected in values: - assert _idval(val, "a", 6, idfn=None, nodeid=None, config=None) == expected + assert ( + IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + ) def test_class_or_function_idval(self) -> None: """Unit test for the expected behavior to obtain ids for parametrized @@ -363,7 +366,9 @@ def test_function(): values = [(TestClass, "TestClass"), (test_function, "test_function")] for val, expected in values: - assert _idval(val, "a", 6, None, nodeid=None, config=None) == expected + assert ( + IdMaker([], [], None, None, None, None)._idval(val, "a", 6) == expected + ) def test_notset_idval(self) -> None: """Test that a NOTSET value (used by an empty parameterset) generates @@ -371,29 +376,43 @@ def test_notset_idval(self) -> None: Regression test for #7686. """ - assert _idval(NOTSET, "a", 0, None, nodeid=None, config=None) == "a0" + assert IdMaker([], [], None, None, None, None)._idval(NOTSET, "a", 0) == "a0" def test_idmaker_autoname(self) -> None: """#250""" - result = idmaker( - ("a", "b"), [pytest.param("string", 1.0), pytest.param("st-ring", 2.0)] - ) + result = IdMaker( + ("a", "b"), + [pytest.param("string", 1.0), pytest.param("st-ring", 2.0)], + None, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == ["string-1.0", "st-ring-2.0"] - result = idmaker( - ("a", "b"), [pytest.param(object(), 1.0), pytest.param(object(), object())] - ) + result = IdMaker( + ("a", "b"), + [pytest.param(object(), 1.0), pytest.param(object(), object())], + 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")]) + result = IdMaker( + ("a", "b"), [pytest.param({}, b"\xc3\xb4")], 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)]) + result = IdMaker( + ("a"), [pytest.param(re.compile(b"foo"), 1.0)], None, None, None, None + ).make_unique_parameterset_ids() assert result == ["foo"] def test_idmaker_native_strings(self) -> None: - result = idmaker( + result = IdMaker( ("a", "b"), [ pytest.param(1.0, -1.1), @@ -410,7 +429,11 @@ def test_idmaker_native_strings(self) -> None: pytest.param(b"\xc3\xb4", "other"), pytest.param(1.0j, -2.0j), ], - ) + None, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == [ "1.0--1.1", "2--202", @@ -428,7 +451,7 @@ def test_idmaker_native_strings(self) -> None: ] def test_idmaker_non_printable_characters(self) -> None: - result = idmaker( + result = IdMaker( ("s", "n"), [ pytest.param("\x00", 1), @@ -438,23 +461,33 @@ def test_idmaker_non_printable_characters(self) -> None: pytest.param("\t", 5), pytest.param(b"\t", 6), ], - ) + None, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == ["\\x00-1", "\\x05-2", "\\x00-3", "\\x05-4", "\\t-5", "\\t-6"] def test_idmaker_manual_ids_must_be_printable(self) -> None: - result = idmaker( + result = IdMaker( ("s",), [ pytest.param("x00", id="hello \x00"), pytest.param("x05", id="hello \x05"), ], - ) + None, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == ["hello \\x00", "hello \\x05"] 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)]) + result = IdMaker( + ("a", "b"), [pytest.param(e.one, e.two)], None, None, None, None + ).make_unique_parameterset_ids() assert result == ["Foo.one-Foo.two"] def test_idmaker_idfn(self) -> None: @@ -465,15 +498,18 @@ def ids(val: object) -> Optional[str]: return repr(val) return None - result = idmaker( + result = IdMaker( ("a", "b"), [ pytest.param(10.0, IndexError()), pytest.param(20, KeyError()), pytest.param("three", [1, 2, 3]), ], - idfn=ids, - ) + ids, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == ["10.0-IndexError()", "20-KeyError()", "three-b2"] def test_idmaker_idfn_unique_names(self) -> None: @@ -482,15 +518,18 @@ def test_idmaker_idfn_unique_names(self) -> None: def ids(val: object) -> str: return "a" - result = idmaker( + result = IdMaker( ("a", "b"), [ pytest.param(10.0, IndexError()), pytest.param(20, KeyError()), pytest.param("three", [1, 2, 3]), ], - idfn=ids, - ) + ids, + None, + None, + None, + ).make_unique_parameterset_ids() assert result == ["a-a0", "a-a1", "a-a2"] def test_idmaker_with_idfn_and_config(self) -> None: @@ -520,12 +559,9 @@ def getini(self, name): (MockConfig({option: False}), "a\\xe7\\xe3o"), ] for config, expected in values: - result = idmaker( - ("a",), - [pytest.param("string")], - idfn=lambda _: "ação", - config=config, - ) + result = IdMaker( + ("a",), [pytest.param("string")], lambda _: "ação", None, config, None + ).make_unique_parameterset_ids() assert result == [expected] def test_idmaker_with_ids_and_config(self) -> None: @@ -555,12 +591,9 @@ def getini(self, name): (MockConfig({option: False}), "a\\xe7\\xe3o"), ] for config, expected in values: - result = idmaker( - ("a",), - [pytest.param("string")], - ids=["ação"], - config=config, - ) + result = IdMaker( + ("a",), [pytest.param("string")], None, ["ação"], config, None + ).make_unique_parameterset_ids() assert result == [expected] def test_parametrize_ids_exception(self, pytester: Pytester) -> None: @@ -617,23 +650,36 @@ def test_int(arg): ) def test_idmaker_with_ids(self) -> None: - result = idmaker( - ("a", "b"), [pytest.param(1, 2), pytest.param(3, 4)], ids=["a", None] - ) + result = IdMaker( + ("a", "b"), + [pytest.param(1, 2), pytest.param(3, 4)], + None, + ["a", None], + None, + None, + ).make_unique_parameterset_ids() assert result == ["a", "3-4"] def test_idmaker_with_paramset_id(self) -> None: - result = idmaker( + result = IdMaker( ("a", "b"), [pytest.param(1, 2, id="me"), pytest.param(3, 4, id="you")], - ids=["a", None], - ) + None, + ["a", None], + None, + None, + ).make_unique_parameterset_ids() assert result == ["me", "you"] def test_idmaker_with_ids_unique_names(self) -> None: - result = idmaker( - ("a"), map(pytest.param, [1, 2, 3, 4, 5]), ids=["a", "a", "b", "c", "b"] - ) + result = IdMaker( + ("a"), + list(map(pytest.param, [1, 2, 3, 4, 5])), + None, + ["a", "a", "b", "c", "b"], + None, + None, + ).make_unique_parameterset_ids() assert result == ["a0", "a1", "b0", "c", "b1"] def test_parametrize_indirect(self) -> None: