Skip to content

Commit b21b008

Browse files
Tobias Deimingerbluetech
authored andcommitted
Refactor idmaker functions into class IdMaker
This commit only refactors, it does not change or add functionality yet. Public API is retained. Reason or refactoring: User provided parameter IDs (e.g. Metafunc.parametrize(ids=...)) had so far only been used to calculate a unique test ID for each test invocation. That test ID was a joined string where each parameter contributed some partial ID. We're soon going to reuse functionality to generate parameter keys for reorder_items and FixtureDef cache. We will be interested in the partial IDs, and only if they originate from explicit user information. Refactoring makes logic and data accessible for reuse, and increases cohesion in general.
1 parent 5c69ece commit b21b008

File tree

2 files changed

+237
-154
lines changed

2 files changed

+237
-154
lines changed

src/_pytest/python.py

Lines changed: 141 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,139 @@ def hasnew(obj: object) -> bool:
929929
return False
930930

931931

932+
@final
933+
@attr.s(frozen=True, auto_attribs=True, slots=True)
934+
class IdMaker:
935+
"""Make IDs for a parametrization."""
936+
937+
# The argnames of the parametrization.
938+
argnames: Sequence[str]
939+
# The ParameterSets of the parametrization.
940+
parametersets: Sequence[ParameterSet]
941+
# Optionally, a user-provided callable to make IDs for parameters in a
942+
# ParameterSet.
943+
idfn: Optional[Callable[[Any], Optional[object]]]
944+
# Optionally, explicit IDs for ParameterSets by index.
945+
ids: Optional[Sequence[Union[None, str]]]
946+
# Optionally, the pytest config.
947+
# Used for controlling ASCII escaping, and for calling the
948+
# :hook:`pytest_make_parametrize_id` hook.
949+
config: Optional[Config]
950+
# Optionally, the ID of the node being parametrized.
951+
# Used only for clearer error messages.
952+
nodeid: Optional[str]
953+
954+
def make_unique_parameterset_ids(self) -> List[str]:
955+
"""Make a unique identifier for each ParameterSet, that may be used to
956+
identify the parametrization in a node ID.
957+
958+
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
959+
- user-provided id, if given
960+
- else an id derived from the value, applicable for certain types
961+
- else <argname><parameterset index>
962+
The counter suffix is appended only in case a string wouldn't be unique
963+
otherwise.
964+
"""
965+
resolved_ids = list(self._resolve_ids())
966+
# All IDs must be unique!
967+
if len(resolved_ids) != len(set(resolved_ids)):
968+
# Record the number of occurrences of each ID.
969+
id_counts = Counter(resolved_ids)
970+
# Map the ID to its next suffix.
971+
id_suffixes: Dict[str, int] = defaultdict(int)
972+
# Suffix non-unique IDs to make them unique.
973+
for index, id in enumerate(resolved_ids):
974+
if id_counts[id] > 1:
975+
resolved_ids[index] = f"{id}{id_suffixes[id]}"
976+
id_suffixes[id] += 1
977+
return resolved_ids
978+
979+
def _resolve_ids(self) -> Iterable[str]:
980+
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
981+
for idx, parameterset in enumerate(self.parametersets):
982+
if parameterset.id is not None:
983+
# ID provided directly - pytest.param(..., id="...")
984+
yield parameterset.id
985+
elif self.ids and idx < len(self.ids) and self.ids[idx] is not None:
986+
# ID provided in the IDs list - parametrize(..., ids=[...]).
987+
id = self.ids[idx]
988+
assert id is not None
989+
yield _ascii_escaped_by_config(id, self.config)
990+
else:
991+
# ID not provided - generate it.
992+
yield "-".join(
993+
self._idval(val, argname, idx)
994+
for val, argname in zip(parameterset.values, self.argnames)
995+
)
996+
997+
def _idval(self, val: object, argname: str, idx: int) -> str:
998+
"""Make an ID for a parameter in a ParameterSet."""
999+
idval = self._idval_from_function(val, argname, idx)
1000+
if idval is not None:
1001+
return idval
1002+
idval = self._idval_from_hook(val, argname)
1003+
if idval is not None:
1004+
return idval
1005+
idval = self._idval_from_value(val)
1006+
if idval is not None:
1007+
return idval
1008+
return self._idval_from_argname(argname, idx)
1009+
1010+
def _idval_from_function(
1011+
self, val: object, argname: str, idx: int
1012+
) -> Optional[str]:
1013+
"""Try to make an ID for a parameter in a ParameterSet using the
1014+
user-provided id callable, if given."""
1015+
if self.idfn is None:
1016+
return None
1017+
try:
1018+
id = self.idfn(val)
1019+
except Exception as e:
1020+
prefix = f"{self.nodeid}: " if self.nodeid is not None else ""
1021+
msg = "error raised while trying to determine id of parameter '{}' at position {}"
1022+
msg = prefix + msg.format(argname, idx)
1023+
raise ValueError(msg) from e
1024+
if id is None:
1025+
return None
1026+
return self._idval_from_value(id)
1027+
1028+
def _idval_from_hook(self, val: object, argname: str) -> Optional[str]:
1029+
"""Try to make an ID for a parameter in a ParameterSet by calling the
1030+
:hook:`pytest_make_parametrize_id` hook."""
1031+
if self.config:
1032+
id: Optional[str] = self.config.hook.pytest_make_parametrize_id(
1033+
config=self.config, val=val, argname=argname
1034+
)
1035+
return id
1036+
return None
1037+
1038+
def _idval_from_value(self, val: object) -> Optional[str]:
1039+
"""Try to make an ID for a parameter in a ParameterSet from its value,
1040+
if the value type is supported."""
1041+
if isinstance(val, STRING_TYPES):
1042+
return _ascii_escaped_by_config(val, self.config)
1043+
elif val is None or isinstance(val, (float, int, bool, complex)):
1044+
return str(val)
1045+
elif isinstance(val, Pattern):
1046+
return ascii_escaped(val.pattern)
1047+
elif val is NOTSET:
1048+
# Fallback to default. Note that NOTSET is an enum.Enum.
1049+
pass
1050+
elif isinstance(val, enum.Enum):
1051+
return str(val)
1052+
elif isinstance(getattr(val, "__name__", None), str):
1053+
# Name of a class, function, module, etc.
1054+
name: str = getattr(val, "__name__")
1055+
return name
1056+
return None
1057+
1058+
@staticmethod
1059+
def _idval_from_argname(argname: str, idx: int) -> str:
1060+
"""Make an ID for a parameter in a ParameterSet from the argument name
1061+
and the index of the ParameterSet."""
1062+
return str(argname) + str(idx)
1063+
1064+
9321065
@final
9331066
@attr.s(frozen=True, slots=True, auto_attribs=True)
9341067
class CallSpec2:
@@ -1217,12 +1350,15 @@ def _resolve_parameter_set_ids(
12171350
else:
12181351
idfn = None
12191352
ids_ = self._validate_ids(ids, parametersets, self.function.__name__)
1220-
return idmaker(argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid)
1353+
id_maker = IdMaker(
1354+
argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid
1355+
)
1356+
return id_maker.make_unique_parameterset_ids()
12211357

12221358
def _validate_ids(
12231359
self,
12241360
ids: Iterable[Union[None, str, float, int, bool]],
1225-
parameters: Sequence[ParameterSet],
1361+
parametersets: Sequence[ParameterSet],
12261362
func_name: str,
12271363
) -> List[Union[None, str]]:
12281364
try:
@@ -1232,12 +1368,12 @@ def _validate_ids(
12321368
iter(ids)
12331369
except TypeError as e:
12341370
raise TypeError("ids must be a callable or an iterable") from e
1235-
num_ids = len(parameters)
1371+
num_ids = len(parametersets)
12361372

12371373
# num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849
1238-
if num_ids != len(parameters) and num_ids != 0:
1374+
if num_ids != len(parametersets) and num_ids != 0:
12391375
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1240-
fail(msg.format(func_name, len(parameters), num_ids), pytrace=False)
1376+
fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False)
12411377

12421378
new_ids = []
12431379
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]) -
13741510
return val if escape_option else ascii_escaped(val) # type: ignore
13751511

13761512

1377-
def _idval(
1378-
val: object,
1379-
argname: str,
1380-
idx: int,
1381-
idfn: Optional[Callable[[Any], Optional[object]]],
1382-
nodeid: Optional[str],
1383-
config: Optional[Config],
1384-
) -> str:
1385-
if idfn:
1386-
try:
1387-
generated_id = idfn(val)
1388-
if generated_id is not None:
1389-
val = generated_id
1390-
except Exception as e:
1391-
prefix = f"{nodeid}: " if nodeid is not None else ""
1392-
msg = "error raised while trying to determine id of parameter '{}' at position {}"
1393-
msg = prefix + msg.format(argname, idx)
1394-
raise ValueError(msg) from e
1395-
elif config:
1396-
hook_id: Optional[str] = config.hook.pytest_make_parametrize_id(
1397-
config=config, val=val, argname=argname
1398-
)
1399-
if hook_id:
1400-
return hook_id
1401-
1402-
if isinstance(val, STRING_TYPES):
1403-
return _ascii_escaped_by_config(val, config)
1404-
elif val is None or isinstance(val, (float, int, bool, complex)):
1405-
return str(val)
1406-
elif isinstance(val, Pattern):
1407-
return ascii_escaped(val.pattern)
1408-
elif val is NOTSET:
1409-
# Fallback to default. Note that NOTSET is an enum.Enum.
1410-
pass
1411-
elif isinstance(val, enum.Enum):
1412-
return str(val)
1413-
elif isinstance(getattr(val, "__name__", None), str):
1414-
# Name of a class, function, module, etc.
1415-
name: str = getattr(val, "__name__")
1416-
return name
1417-
return str(argname) + str(idx)
1418-
1419-
1420-
def _idvalset(
1421-
idx: int,
1422-
parameterset: ParameterSet,
1423-
argnames: Iterable[str],
1424-
idfn: Optional[Callable[[Any], Optional[object]]],
1425-
ids: Optional[List[Union[None, str]]],
1426-
nodeid: Optional[str],
1427-
config: Optional[Config],
1428-
) -> str:
1429-
if parameterset.id is not None:
1430-
return parameterset.id
1431-
id = None if ids is None or idx >= len(ids) else ids[idx]
1432-
if id is None:
1433-
this_id = [
1434-
_idval(val, argname, idx, idfn, nodeid=nodeid, config=config)
1435-
for val, argname in zip(parameterset.values, argnames)
1436-
]
1437-
return "-".join(this_id)
1438-
else:
1439-
return _ascii_escaped_by_config(id, config)
1440-
1441-
1442-
def idmaker(
1443-
argnames: Iterable[str],
1444-
parametersets: Iterable[ParameterSet],
1445-
idfn: Optional[Callable[[Any], Optional[object]]] = None,
1446-
ids: Optional[List[Union[None, str]]] = None,
1447-
config: Optional[Config] = None,
1448-
nodeid: Optional[str] = None,
1449-
) -> List[str]:
1450-
resolved_ids = [
1451-
_idvalset(
1452-
valindex, parameterset, argnames, idfn, ids, config=config, nodeid=nodeid
1453-
)
1454-
for valindex, parameterset in enumerate(parametersets)
1455-
]
1456-
1457-
# All IDs must be unique!
1458-
unique_ids = set(resolved_ids)
1459-
if len(unique_ids) != len(resolved_ids):
1460-
1461-
# Record the number of occurrences of each test ID.
1462-
test_id_counts = Counter(resolved_ids)
1463-
1464-
# Map the test ID to its next suffix.
1465-
test_id_suffixes: Dict[str, int] = defaultdict(int)
1466-
1467-
# Suffix non-unique IDs to make them unique.
1468-
for index, test_id in enumerate(resolved_ids):
1469-
if test_id_counts[test_id] > 1:
1470-
resolved_ids[index] = f"{test_id}{test_id_suffixes[test_id]}"
1471-
test_id_suffixes[test_id] += 1
1472-
1473-
return resolved_ids
1474-
1475-
14761513
def _pretty_fixture_path(func) -> str:
14771514
cwd = Path.cwd()
14781515
loc = Path(getlocation(func, str(cwd)))

0 commit comments

Comments
 (0)