Add IdMaker.make_parameter_keys

These parameter keys will later become the unified way how reorder_items
and FixtureDefs decide if parameters are equal and can be reused.

Order for what to use as key is as follows:
1. If users gave explicitly parameter ids, use them as key.
2. If not explictely given, and the parameter value is hashable, use the
   parameter value as key.
3. Else, fallback to the parameters identity.

NB: Rule 1 gives users ultimate (equallity-telling) power, and with great
power comes great responsiblity. One could now do something wired like

@pytest.mark.parametrize(fruit, [
    pytest.param("apple", id="fruit"),
    pytest.param("orange", id="fruit"),
]
def test_fruits(fruit):
   pass

The user just made "apple" equal to "orange". If that's what they intend
is unknown, but probably not.
This commit is contained in:
Tobias Deiminger 2021-12-16 21:02:37 +01:00 committed by Ran Benita
parent cd8bfa94ec
commit e2c88eaf98
1 changed files with 68 additions and 0 deletions

View File

@ -9,6 +9,7 @@ import types
import warnings import warnings
from collections import Counter from collections import Counter
from collections import defaultdict from collections import defaultdict
from collections.abc import Hashable
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -929,6 +930,38 @@ def hasnew(obj: object) -> bool:
return False return False
@attr.s(auto_attribs=True, eq=False, slots=True)
class SafeHashWrapper:
"""Wrap an arbitrary type so that it becomes comparable with guaranteed constraints.
Constraints:
- SafeHashWrapper(a) == SafeHashWrapper(b) will never raise an exception
- SafeHashWrapper(a) == SafeHashWrapper(b) will always return bool
(oddly some inner types wouldn't, e.g. numpy.array([0]) == numpy.array([0]) returns List)
- SafeHashWrapper(a) is always hashable
- if SafeHashWrapper(a) == SafeHashWrapper(b),
then hash(SafeHashWrapper(a)) == hash(SafeHashWrapper(b))
It works by falling back to identity compare in case constraints couldn't be met otherwise.
"""
obj: Any
def __eq__(self, other: object) -> bool:
if isinstance(self.obj, Hashable) and isinstance(other, Hashable):
try:
res = self.obj == other
return bool(res)
except Exception:
pass
return self.obj is other
def __hash__(self) -> int:
if isinstance(self.obj, Hashable):
return hash(self.obj)
return hash(id(self.obj))
@final @final
@attr.s(frozen=True, auto_attribs=True, slots=True) @attr.s(frozen=True, auto_attribs=True, slots=True)
class IdMaker: class IdMaker:
@ -976,6 +1009,27 @@ class IdMaker:
id_suffixes[id] += 1 id_suffixes[id] += 1
return resolved_ids return resolved_ids
def make_parameter_keys(self) -> Iterable[Dict[str, Hashable]]:
"""Make hashable parameter keys for each ParameterSet.
For each ParameterSet, generates a dict mapping each parameter to its key.
This key will be considered (along with the arguments name) to determine
if parameters are the same in the sense of reorder_items() and the
FixtureDef cache. The key is guaranteed to be hashable and comparable.
It's not intended for printing and therefore not ASCII escaped.
"""
for idx, parameterset in enumerate(self.parametersets):
if parameterset.id is not None:
# ID provided directly - pytest.param(..., id="...")
yield {argname: parameterset.id for argname in self.argnames}
elif self.ids and idx < len(self.ids) and self.ids[idx] is not None:
# ID provided in the IDs list - parametrize(..., ids=[...]).
yield {argname: self.ids[idx] for argname in self.argnames}
else:
# ID not provided - generate it.
yield self._parameter_keys_from_parameterset(parameterset, idx)
def _resolve_ids(self) -> Iterable[str]: def _resolve_ids(self) -> Iterable[str]:
"""Resolve IDs for all ParameterSets (may contain duplicates).""" """Resolve IDs for all ParameterSets (may contain duplicates)."""
for idx, parameterset in enumerate(self.parametersets): for idx, parameterset in enumerate(self.parametersets):
@ -994,6 +1048,20 @@ class IdMaker:
for val, argname in zip(parameterset.values, self.argnames) for val, argname in zip(parameterset.values, self.argnames)
) )
def _parameter_keys_from_parameterset(
self, parameterset: ParameterSet, idx: int
) -> Dict[str, Hashable]:
"""Make parameter keys for all parameters in a ParameterSet."""
param_keys: Dict[str, Hashable] = {}
for val, argname in zip(parameterset.values, self.argnames):
evaluated_id = self._idval_from_function(val, argname, idx)
if evaluated_id is not None:
param_keys[argname] = evaluated_id
else:
# Wrapping ensures val becomes comparable and hashable.
param_keys[argname] = SafeHashWrapper(val)
return param_keys
def _idval(self, val: object, argname: str, idx: int) -> str: def _idval(self, val: object, argname: str, idx: int) -> str:
"""Make an ID for a parameter in a ParameterSet.""" """Make an ID for a parameter in a ParameterSet."""
idval = self._idval_from_function(val, argname, idx) idval = self._idval_from_function(val, argname, idx)