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.
This commit is contained in:
Tobias Deiminger 2021-12-16 21:02:27 +01:00 committed by Ran Benita
parent 5c69eced6c
commit b21b008118
2 changed files with 237 additions and 154 deletions

View File

@ -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 <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
- user-provided id, if given
- else an id derived from the value, applicable for certain types
- else <argname><parameterset index>
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 @@ class Metafunc:
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 @@ class Metafunc:
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)))

View File

@ -24,8 +24,7 @@ from _pytest.compat import getfuncargnames
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 TestMetafunc:
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 @@ class TestMetafunc:
),
]
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 @@ class TestMetafunc:
("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 @@ class TestMetafunc:
("αρά".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 @@ class TestMetafunc:
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 @@ class TestMetafunc:
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 @@ class TestMetafunc:
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 @@ class TestMetafunc:
]
def test_idmaker_non_printable_characters(self) -> None:
result = idmaker(
result = IdMaker(
("s", "n"),
[
pytest.param("\x00", 1),
@ -438,23 +461,33 @@ class TestMetafunc:
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 @@ class TestMetafunc:
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 @@ class TestMetafunc:
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 @@ class TestMetafunc:
(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 @@ class TestMetafunc:
(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 @@ class TestMetafunc:
)
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: