This commit is contained in:
Tobias Deiminger 2022-02-14 10:46:08 -03:00 committed by GitHub
commit 5622a6c4ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 164 additions and 22 deletions

View File

@ -322,6 +322,7 @@ Thomas Grainger
Thomas Hisch Thomas Hisch
Tim Hoffmann Tim Hoffmann
Tim Strazny Tim Strazny
Tobias Deiminger
Tom Dalton Tom Dalton
Tom Viner Tom Viner
Tomáš Gavenčiak Tomáš Gavenčiak

View File

@ -0,0 +1,4 @@
If fixtures had been indirectly parameterized via test function, e.g. using the
``@pytest.mark.parametrize(indirect=True)`` marker, reordering of tests for the least possible fixture setup/teardown
cycles did not work. Optimized test groups can now be determined either explicitly by passing parameter ids, or
implicitly if the parameter value is hashable.

View File

@ -5,6 +5,7 @@ import sys
import warnings import warnings
from collections import defaultdict from collections import defaultdict
from collections import deque from collections import deque
from collections.abc import Hashable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from types import TracebackType from types import TracebackType
@ -248,21 +249,21 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K
pass pass
else: else:
cs: CallSpec2 = callspec cs: CallSpec2 = callspec
# cs.indices.items() is random order of argnames. Need to # cs.param_keys.items() is random order of argnames. Need to
# sort this so that different calls to # sort this so that different calls to
# get_parametrized_fixture_keys will be deterministic. # get_parametrized_fixture_keys will be deterministic.
for argname, param_index in sorted(cs.indices.items()): for argname, param_key in sorted(cs.param_keys.items()):
if cs._arg2scope[argname] != scope: if cs._arg2scope[argname] != scope:
continue continue
if scope is Scope.Session: if scope is Scope.Session:
key: _Key = (argname, param_index) key: _Key = (argname, param_key)
elif scope is Scope.Package: elif scope is Scope.Package:
key = (argname, param_index, item.path.parent) key = (argname, param_key, item.path.parent)
elif scope is Scope.Module: elif scope is Scope.Module:
key = (argname, param_index, item.path) key = (argname, param_key, item.path)
elif scope is Scope.Class: elif scope is Scope.Class:
item_cls = item.cls # type: ignore[attr-defined] item_cls = item.cls # type: ignore[attr-defined]
key = (argname, param_index, item.path, item_cls) key = (argname, param_key, item.path, item_cls)
else: else:
assert_never(scope) assert_never(scope)
yield key yield key
@ -601,6 +602,7 @@ class FixtureRequest:
except (AttributeError, ValueError): except (AttributeError, ValueError):
param = NOTSET param = NOTSET
param_index = 0 param_index = 0
param_key = ""
has_params = fixturedef.params is not None has_params = fixturedef.params is not None
fixtures_not_supported = getattr(funcitem, "nofuncargs", False) fixtures_not_supported = getattr(funcitem, "nofuncargs", False)
if has_params and fixtures_not_supported: if has_params and fixtures_not_supported:
@ -640,13 +642,14 @@ class FixtureRequest:
fail(msg, pytrace=False) fail(msg, pytrace=False)
else: else:
param_index = funcitem.callspec.indices[argname] param_index = funcitem.callspec.indices[argname]
param_key = funcitem.callspec.param_keys[argname]
# If a parametrize invocation set a scope it will override # If a parametrize invocation set a scope it will override
# the static scope defined with the fixture function. # the static scope defined with the fixture function.
with suppress(KeyError): with suppress(KeyError):
scope = funcitem.callspec._arg2scope[argname] scope = funcitem.callspec._arg2scope[argname]
subrequest = SubRequest( subrequest = SubRequest(
self, scope, param, param_index, fixturedef, _ispytest=True self, scope, param, param_index, param_key, fixturedef, _ispytest=True
) )
# Check if a higher-level scoped fixture accesses a lower level one. # Check if a higher-level scoped fixture accesses a lower level one.
@ -731,6 +734,7 @@ class SubRequest(FixtureRequest):
scope: Scope, scope: Scope,
param: Any, param: Any,
param_index: int, param_index: int,
param_key: Hashable,
fixturedef: "FixtureDef[object]", fixturedef: "FixtureDef[object]",
*, *,
_ispytest: bool = False, _ispytest: bool = False,
@ -741,6 +745,7 @@ class SubRequest(FixtureRequest):
if param is not NOTSET: if param is not NOTSET:
self.param = param self.param = param
self.param_index = param_index self.param_index = param_index
self.param_key = param_key
self._scope = scope self._scope = scope
self._fixturedef = fixturedef self._fixturedef = fixturedef
self._pyfuncitem = request._pyfuncitem self._pyfuncitem = request._pyfuncitem
@ -1012,10 +1017,10 @@ class FixtureDef(Generic[FixtureValue]):
my_cache_key = self.cache_key(request) my_cache_key = self.cache_key(request)
if self.cached_result is not None: if self.cached_result is not None:
# note: comparison with `==` can fail (or be expensive) for e.g.
# numpy arrays (#6497).
cache_key = self.cached_result[1] cache_key = self.cached_result[1]
if my_cache_key is cache_key: # Note: Comparison with `==` may be implemented as (possibly expensive)
# deep by-value comparison. See _pytest.python.SafeHashWrapper for details.
if my_cache_key == cache_key:
if self.cached_result[2] is not None: if self.cached_result[2] is not None:
_, val, tb = self.cached_result[2] _, val, tb = self.cached_result[2]
raise val.with_traceback(tb) raise val.with_traceback(tb)
@ -1032,7 +1037,7 @@ class FixtureDef(Generic[FixtureValue]):
return result return result
def cache_key(self, request: SubRequest) -> object: def cache_key(self, request: SubRequest) -> object:
return request.param_index if not hasattr(request, "param") else request.param return request.param_key
def __repr__(self) -> str: def __repr__(self) -> str:
return "<FixtureDef argname={!r} scope={!r} baseid={!r}>".format( return "<FixtureDef argname={!r} scope={!r} baseid={!r}>".format(

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
@ -927,6 +928,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:
@ -974,6 +1007,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):
@ -992,6 +1046,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)
@ -1076,6 +1144,8 @@ class CallSpec2:
# arg name -> arg value which will be passed to a fixture of the same name # arg name -> arg value which will be passed to a fixture of the same name
# (indirect parametrization). # (indirect parametrization).
params: Dict[str, object] = attr.Factory(dict) params: Dict[str, object] = attr.Factory(dict)
# arg name -> parameter key.
param_keys: Dict[str, Hashable] = attr.Factory(dict)
# arg name -> arg index. # arg name -> arg index.
indices: Dict[str, int] = attr.Factory(dict) indices: Dict[str, int] = attr.Factory(dict)
# Used for sorting parametrized resources. # Used for sorting parametrized resources.
@ -1095,9 +1165,12 @@ class CallSpec2:
marks: Iterable[Union[Mark, MarkDecorator]], marks: Iterable[Union[Mark, MarkDecorator]],
scope: Scope, scope: Scope,
param_index: int, param_index: int,
param_set_keys: Dict[str, Hashable],
) -> "CallSpec2": ) -> "CallSpec2":
"""Extend an existing callspec with new parameters during multiple invocation of Metafunc.parametrize."""
funcargs = self.funcargs.copy() funcargs = self.funcargs.copy()
params = self.params.copy() params = self.params.copy()
param_keys = self.param_keys.copy()
indices = self.indices.copy() indices = self.indices.copy()
arg2scope = self._arg2scope.copy() arg2scope = self._arg2scope.copy()
for arg, val in zip(argnames, valset): for arg, val in zip(argnames, valset):
@ -1111,10 +1184,12 @@ class CallSpec2:
else: else:
assert_never(valtype_for_arg) assert_never(valtype_for_arg)
indices[arg] = param_index indices[arg] = param_index
param_keys[arg] = param_set_keys[arg]
arg2scope[arg] = scope arg2scope[arg] = scope
return CallSpec2( return CallSpec2(
funcargs=funcargs, funcargs=funcargs,
params=params, params=params,
param_keys=param_keys,
arg2scope=arg2scope, arg2scope=arg2scope,
indices=indices, indices=indices,
idlist=[*self._idlist, id], idlist=[*self._idlist, id],
@ -1284,7 +1359,7 @@ class Metafunc:
if generated_ids is not None: if generated_ids is not None:
ids = generated_ids ids = generated_ids
ids = self._resolve_parameter_set_ids( ids, parameters_keys = self._resolve_parameter_set_ids(
argnames, ids, parametersets, nodeid=self.definition.nodeid argnames, ids, parametersets, nodeid=self.definition.nodeid
) )
@ -1297,17 +1372,18 @@ class Metafunc:
# of all calls. # of all calls.
newcalls = [] newcalls = []
for callspec in self._calls or [CallSpec2()]: for callspec in self._calls or [CallSpec2()]:
for param_index, (param_id, param_set) in enumerate( for param_index, (param_id, parameterset, param_set_keys) in enumerate(
zip(ids, parametersets) zip(ids, parametersets, parameters_keys)
): ):
newcallspec = callspec.setmulti( newcallspec = callspec.setmulti(
valtypes=arg_values_types, valtypes=arg_values_types,
argnames=argnames, argnames=argnames,
valset=param_set.values, valset=parameterset.values,
id=param_id, id=param_id,
marks=param_set.marks, marks=parameterset.marks,
scope=scope_, scope=scope_,
param_index=param_index, param_index=param_index,
param_set_keys=param_set_keys,
) )
newcalls.append(newcallspec) newcalls.append(newcallspec)
self._calls = newcalls self._calls = newcalls
@ -1323,9 +1399,8 @@ class Metafunc:
], ],
parametersets: Sequence[ParameterSet], parametersets: Sequence[ParameterSet],
nodeid: str, nodeid: str,
) -> List[str]: ) -> Tuple[List[str], List[Dict[str, Hashable]]]:
"""Resolve the actual ids for the given parameter sets. """Resolve the actual ids for the given parameter sets.
:param argnames: :param argnames:
Argument names passed to ``parametrize()``. Argument names passed to ``parametrize()``.
:param ids: :param ids:
@ -1337,7 +1412,9 @@ class Metafunc:
The nodeid of the definition item that generated this The nodeid of the definition item that generated this
parametrization. parametrization.
:returns: :returns:
List with ids for each parameter set given. Tuple, where
1st entry is a list with ids for each parameter set given used to name test invocations, and
2nd entry is a list with keys to support distinction of parameters to support fixture reuse.
""" """
if ids is None: if ids is None:
idfn = None idfn = None
@ -1351,7 +1428,9 @@ class Metafunc:
id_maker = IdMaker( id_maker = IdMaker(
argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid argnames, parametersets, idfn, ids_, self.config, nodeid=nodeid
) )
return id_maker.make_unique_parameterset_ids() return id_maker.make_unique_parameterset_ids(), list(
id_maker.make_parameter_keys()
)
def _validate_ids( def _validate_ids(
self, self,

View File

@ -22,13 +22,13 @@ def checked_order():
assert order == [ assert order == [
("issue_519.py", "fix1", "arg1v1"), ("issue_519.py", "fix1", "arg1v1"),
("test_one[arg1v1-arg2v1]", "fix2", "arg2v1"), ("test_one[arg1v1-arg2v1]", "fix2", "arg2v1"),
("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"),
("test_one[arg1v1-arg2v2]", "fix2", "arg2v2"), ("test_one[arg1v1-arg2v2]", "fix2", "arg2v2"),
("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"),
("test_two[arg1v1-arg2v2]", "fix2", "arg2v2"), ("test_two[arg1v1-arg2v2]", "fix2", "arg2v2"),
("issue_519.py", "fix1", "arg1v2"), ("issue_519.py", "fix1", "arg1v2"),
("test_one[arg1v2-arg2v1]", "fix2", "arg2v1"), ("test_one[arg1v2-arg2v1]", "fix2", "arg2v1"),
("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"),
("test_one[arg1v2-arg2v2]", "fix2", "arg2v2"), ("test_one[arg1v2-arg2v2]", "fix2", "arg2v2"),
("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"),
("test_two[arg1v2-arg2v2]", "fix2", "arg2v2"), ("test_two[arg1v2-arg2v2]", "fix2", "arg2v2"),
] ]

View File

@ -1308,6 +1308,59 @@ class TestFixtureUsages:
result = pytester.runpytest() result = pytester.runpytest()
result.stdout.fnmatch_lines(["*4 passed*"]) result.stdout.fnmatch_lines(["*4 passed*"])
@pytest.mark.parametrize(
("parametrize1", "parametrize2"),
[
(
'"fix", [1, 2], indirect=True',
'"fix", [2, 1], indirect=True',
),
(
'"fix", [1, pytest.param({"data": 2}, id="2")], indirect=True',
'"fix", [pytest.param({"data": 2}, id="2"), 1], indirect=True',
),
(
'"fix", [{"data": 1}, {"data": 2}], indirect=True, ids=lambda d: MyEnum(d["data"])',
'"fix", [{"data": 2}, {"data": 1}], indirect=True, ids=lambda d: MyEnum(d["data"])',
),
(
'"fix", [{"data": 1}, {"data": 2}], indirect=True, ids=[1, "two"]',
'"fix", [{"data": 2}, {"data": 1}], indirect=True, ids=["two", 1]',
),
],
)
def test_reorder_and_cache(
self, pytester: Pytester, parametrize1, parametrize2
) -> None:
"""Test optimization for minimal setup/teardown with indirectly parametrized fixtures. See #8914, #9420."""
pytester.makepyfile(
f"""
import pytest
from enum import Enum
class MyEnum(Enum):
Id1 = 1
Id2 = 2
@pytest.fixture(scope="session")
def fix(request):
value = request.param["data"] if isinstance(request.param, dict) else request.param
print(f'prepare foo-%s' % value)
yield value
print(f'teardown foo-%s' % value)
@pytest.mark.parametrize({parametrize1})
def test1(fix):
pass
@pytest.mark.parametrize({parametrize2})
def test2(fix):
pass
"""
)
result = pytester.runpytest("-s")
output = result.stdout.str()
assert output.count("prepare foo-1") == 1
assert output.count("prepare foo-2") == 1
assert output.count("teardown foo-1") == 1
assert output.count("teardown foo-2") == 1
def test_funcarg_parametrized_and_used_twice(self, pytester: Pytester) -> None: def test_funcarg_parametrized_and_used_twice(self, pytester: Pytester) -> None:
pytester.makepyfile( pytester.makepyfile(
""" """