diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f2bf5320c..8882486b0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -15,6 +15,7 @@ from typing import Final from typing import final from typing import Generator from typing import Generic +from typing import Hashable from typing import Iterable from typing import Iterator from typing import List @@ -239,11 +240,28 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: ) -# Parametrized fixture key, helper alias for code below. -_Key = Tuple[object, ...] +@dataclasses.dataclass(frozen=True) +class FixtureArgKeyByIndex: + argname: str + param_index: int + scoped_item_path: Optional[Path] + item_cls: Optional[type] -def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]: +@dataclasses.dataclass(frozen=True) +class FixtureArgKeyByValue: + argname: str + param_value: Hashable + scoped_item_path: Optional[Path] + item_cls: Optional[type] + + +FixtureArgKey = Union[FixtureArgKeyByIndex, FixtureArgKeyByValue] + + +def get_parametrized_fixture_keys( + item: nodes.Item, scope: Scope +) -> Iterator[FixtureArgKey]: """Return list of keys for all parametrized arguments which match the specified scope.""" assert scope is not Scope.Function @@ -256,21 +274,33 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K # cs.indices.items() is random order of argnames. Need to # sort this so that different calls to # get_parametrized_fixture_keys will be deterministic. - for argname, param_index in sorted(cs.indices.items()): + for argname in sorted(cs.indices): if cs._arg2scope[argname] != scope: continue + + item_cls = None if scope is Scope.Session: - key: _Key = (argname, param_index) + scoped_item_path = None elif scope is Scope.Package: - key = (argname, param_index, item.path) + scoped_item_path = item.path.parent elif scope is Scope.Module: - key = (argname, param_index, item.path) + scoped_item_path = item.path elif scope is Scope.Class: + scoped_item_path = item.path item_cls = item.cls # type: ignore[attr-defined] - key = (argname, param_index, item.path, item_cls) else: assert_never(scope) - yield key + + param_index = cs.indices[argname] + param_value = cs.params[argname] + if isinstance(param_value, Hashable): + yield FixtureArgKeyByValue( + argname, param_value, scoped_item_path, item_cls + ) + else: + yield FixtureArgKeyByIndex( # type: ignore[unreachable] + argname, param_index, scoped_item_path, item_cls + ) # Algorithm for sorting on a per-parametrized resource setup basis. @@ -280,12 +310,12 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]] = {} - items_by_argkey: Dict[Scope, Dict[_Key, Deque[nodes.Item]]] = {} + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]] = {} + items_by_argkey: Dict[Scope, Dict[FixtureArgKey, Deque[nodes.Item]]] = {} for scope in HIGH_SCOPES: - d: Dict[nodes.Item, Dict[_Key, None]] = {} + d: Dict[nodes.Item, Dict[FixtureArgKey, None]] = {} argkeys_cache[scope] = d - item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque) + item_d: Dict[FixtureArgKey, Deque[nodes.Item]] = defaultdict(deque) items_by_argkey[scope] = item_d for item in items: keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) @@ -301,8 +331,8 @@ def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: def fix_cache_order( item: nodes.Item, - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]], + items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]], ) -> None: for scope in HIGH_SCOPES: for key in argkeys_cache[scope].get(item, []): @@ -311,13 +341,13 @@ def fix_cache_order( def reorder_items_atscope( items: Dict[nodes.Item, None], - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]], + items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]], scope: Scope, ) -> Dict[nodes.Item, None]: if scope is Scope.Function or len(items) < 3: return items - ignore: Set[Optional[_Key]] = set() + ignore: Set[Optional[FixtureArgKey]] = set() items_deque = deque(items) items_done: Dict[nodes.Item, None] = {} scoped_items_by_argkey = items_by_argkey[scope] diff --git a/testing/example_scripts/issue_519.py b/testing/example_scripts/issue_519.py index e44367fca..73437ef7b 100644 --- a/testing/example_scripts/issue_519.py +++ b/testing/example_scripts/issue_519.py @@ -22,13 +22,13 @@ def checked_order(): assert order == [ ("issue_519.py", "fix1", "arg1v1"), ("test_one[arg1v1-arg2v1]", "fix2", "arg2v1"), - ("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"), ("test_one[arg1v1-arg2v2]", "fix2", "arg2v2"), + ("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"), ("test_two[arg1v1-arg2v2]", "fix2", "arg2v2"), ("issue_519.py", "fix1", "arg1v2"), ("test_one[arg1v2-arg2v1]", "fix2", "arg2v1"), - ("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"), ("test_one[arg1v2-arg2v2]", "fix2", "arg2v2"), + ("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"), ("test_two[arg1v2-arg2v2]", "fix2", "arg2v2"), ] diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 191689d1c..1e05bf4c4 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -12,6 +12,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import get_public_names from _pytest.pytester import Pytester from _pytest.python import Function +from _pytest.scope import Scope def test_getfuncargnames_functions(): @@ -4536,3 +4537,201 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None: result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED + + +@pytest.mark.parametrize("scope", ["module", "package"]) +def test_basing_fixture_argkeys_on_param_values_rather_than_on_param_indices( + scope, + pytester: Pytester, +): + package = pytester.mkdir("package") + package.joinpath("__init__.py").write_text("", encoding="utf-8") + package.joinpath("test_a.py").write_text( + textwrap.dedent( + f"""\ + import pytest + + @pytest.fixture(scope='{scope}') + def fixture1(request): + pass + + @pytest.mark.parametrize("fixture1", [1, 0], indirect=True) + def test_0(fixture1): + pass + + @pytest.mark.parametrize("fixture1", [2, 1], indirect=True) + def test_1(fixture1): + pass + + def test_2(): + pass + + @pytest.mark.parametrize("param", [0, 1, 2], scope='{scope}') + def test_3(param): + pass + + @pytest.mark.parametrize("param", [2, 1, 0], scope='{scope}') + def test_4(param): + pass + """ + ), + encoding="utf-8", + ) + result = pytester.runpytest("--collect-only") + result.stdout.re_match_lines( + [ + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + ] + ) + + +def test_basing_fixture_argkeys_on_param_values_rather_than_on_param_indices_2( + pytester: Pytester, +): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope='module') + def fixture1(request): + pass + + @pytest.fixture(scope='module') + def fixture2(request): + pass + + @pytest.mark.parametrize("fixture1, fixture2", [("a", 0), ("b", 1), ("a", 2)], indirect=True) + def test_1(fixture1, fixture2): + pass + + @pytest.mark.parametrize("fixture1, fixture2", [("c", 4), ("a", 3)], indirect=True) + def test_2(fixture1, fixture2): + pass + + def test_3(): + pass + + @pytest.mark.parametrize("param1, param2", [("a", 0), ("b", 1), ("a", 2)], scope='module') + def test_4(param1, param2): + pass + + @pytest.mark.parametrize("param1, param2", [("c", 4), ("a", 3)], scope='module') + def test_5(param1, param2): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.re_match_lines( + [ + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + ] + ) + + +@pytest.mark.xfail( + reason="It isn't differentiated between direct `fixture` param and fixture `fixture`. Will be" + "solved by adding `baseid` to `FixtureArgKey`." +) +def test_reorder_with_high_scoped_direct_and_fixture_parametrization( + pytester: Pytester, +): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(params=[0, 1], scope='module') + def fixture(request): + pass + + def test_1(fixture): + pass + + def test_2(): + pass + + @pytest.mark.parametrize("fixture", [1, 2], scope='module') + def test_3(fixture): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.re_match_lines( + [ + r" ", + r" ", + r" ", + r" ", + r" ", + ] + ) + + +def test_get_parametrized_fixture_keys_with_unhashable_params( + pytester: Pytester, +) -> None: + module = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("arg", [[1], [2]], scope='module') + def test(arg): + pass + """ + ) + test_0, test_1 = pytester.genitems((pytester.getmodulecol(module),)) + test_0_keys = list(fixtures.get_parametrized_fixture_keys(test_0, Scope.Module)) + test_1_keys = list(fixtures.get_parametrized_fixture_keys(test_1, Scope.Module)) + assert len(test_0_keys) == len(test_1_keys) == 1 + assert isinstance(test_0_keys[0], fixtures.FixtureArgKeyByIndex) + assert test_0_keys[0].param_index == 0 + assert isinstance(test_1_keys[0], fixtures.FixtureArgKeyByIndex) + assert test_1_keys[0].param_index == 1 + + +def test_reordering_with_unhashable_parametrize_args(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("arg", [[1], [2]], scope='module') + def test_1(arg): + print(arg) + + def test_2(): + print("test_2") + + @pytest.mark.parametrize("arg", [[3], [4]], scope='module') + def test_3(arg): + print(arg) + """ + ) + result = pytester.runpytest("-s") + result.stdout.fnmatch_lines( + [ + r"*1*", + r"*3*", + r"*2*", + r"*4*", + r"*test_2*", + ] + )