Skip to content

Commit 04bddfc

Browse files
authored
Merge pull request #9547 from bluetech/refactor-idmaker
Refactor idmaker functions into class IdMaker
2 parents 5c69ece + b21b008 commit 04bddfc

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)