diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bed87035..be6d08221 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: language: python files: \.py$ - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 language_version: python3 @@ -42,7 +42,7 @@ repos: - id: reorder-python-imports args: ['--application-directories=.:src', --py38-plus] - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [--py38-plus] diff --git a/changelog/11277.bugfix.rst b/changelog/11277.bugfix.rst new file mode 100644 index 000000000..43370561e --- /dev/null +++ b/changelog/11277.bugfix.rst @@ -0,0 +1,2 @@ +Fixed a bug that when there are multiple fixtures for an indirect parameter, +the scope of the highest-scope fixture is picked for the parameter set, instead of that of the one with the narrowest scope. diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index f771e5da4..4ea6f6e65 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -657,13 +657,16 @@ Use :func:`pytest.raises` with the :ref:`pytest.mark.parametrize ref` decorator to write parametrized tests in which some tests raise exceptions and others do not. -It may be helpful to use ``nullcontext`` as a complement to ``raises``. +``contextlib.nullcontext`` can be used to test cases that are not expected to +raise exceptions but that should result in some value. The value is given as the +``enter_result`` parameter, which will be available as the ``with`` statement’s +target (``e`` in the example below). For example: .. code-block:: python - from contextlib import nullcontext as does_not_raise + from contextlib import nullcontext import pytest @@ -671,16 +674,17 @@ For example: @pytest.mark.parametrize( "example_input,expectation", [ - (3, does_not_raise()), - (2, does_not_raise()), - (1, does_not_raise()), + (3, nullcontext(2)), + (2, nullcontext(3)), + (1, nullcontext(6)), (0, pytest.raises(ZeroDivisionError)), ], ) def test_division(example_input, expectation): """Test how much I know division.""" - with expectation: - assert (6 / example_input) is not None + with expectation as e: + assert (6 / example_input) == e -In the example above, the first three test cases should run unexceptionally, -while the fourth should raise ``ZeroDivisionError``. +In the example above, the first three test cases should run without any +exceptions, while the fourth should raise a``ZeroDivisionError`` exception, +which is expected by pytest. diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index 1b10c1313..d99a1ce5c 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -54,14 +54,13 @@ operators. (See :ref:`tbreportdemo`). This allows you to use the idiomatic python constructs without boilerplate code while not losing introspection information. -However, if you specify a message with the assertion like this: +If a message is specified with the assertion like this: .. code-block:: python assert a % 2 == 0, "value was odd, should be even" -then no assertion introspection takes places at all and the message -will be simply shown in the traceback. +it is printed alongside the assertion introspection in the traceback. See :ref:`assert-details` for more information on assertion introspection. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index ea008110e..a6d7cfdd4 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -82,6 +82,8 @@ pytest.exit pytest.main ~~~~~~~~~~~ +**Tutorial**: :ref:`pytest.main-usage` + .. autofunction:: pytest.main pytest.param diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index fc5dfdbd5..268f714ba 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -222,7 +222,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: other_side = right if isinstance(left, ApproxBase) else left explanation = approx_side._repr_compare(other_side) - elif type(left) == type(right) and ( + elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) ): # Note: unlike dataclasses/attrs, namedtuples compare only the diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 2b6f250f3..8dbaf7c70 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -581,26 +581,25 @@ class PytestPluginManager(PluginManager): def _try_load_conftest( self, anchor: Path, importmode: Union[str, ImportMode], rootpath: Path ) -> None: - self._getconftestmodules(anchor, importmode, rootpath) + self._loadconftestmodules(anchor, importmode, rootpath) # let's also consider test* subdirs if anchor.is_dir(): for x in anchor.glob("test*"): if x.is_dir(): - self._getconftestmodules(x, importmode, rootpath) + self._loadconftestmodules(x, importmode, rootpath) - def _getconftestmodules( + def _loadconftestmodules( self, path: Path, importmode: Union[str, ImportMode], rootpath: Path - ) -> Sequence[types.ModuleType]: + ) -> None: if self._noconftest: - return [] + return directory = self._get_directory(path) # Optimization: avoid repeated searches in the same directory. # Assumes always called with same importmode and rootpath. - existing_clist = self._dirpath2confmods.get(directory) - if existing_clist is not None: - return existing_clist + if directory in self._dirpath2confmods: + return # XXX these days we may rather want to use config.rootpath # and allow users to opt into looking into the rootdir parent @@ -613,16 +612,17 @@ class PytestPluginManager(PluginManager): mod = self._importconftest(conftestpath, importmode, rootpath) clist.append(mod) self._dirpath2confmods[directory] = clist - return clist + + def _getconftestmodules(self, path: Path) -> Sequence[types.ModuleType]: + directory = self._get_directory(path) + return self._dirpath2confmods.get(directory, ()) def _rget_with_confmod( self, name: str, path: Path, - importmode: Union[str, ImportMode], - rootpath: Path, ) -> Tuple[types.ModuleType, Any]: - modules = self._getconftestmodules(path, importmode, rootpath=rootpath) + modules = self._getconftestmodules(path) for mod in reversed(modules): try: return mod, getattr(mod, name) @@ -1562,13 +1562,9 @@ class Config: else: return self._getini_unknown_type(name, type, value) - def _getconftest_pathlist( - self, name: str, path: Path, rootpath: Path - ) -> Optional[List[Path]]: + def _getconftest_pathlist(self, name: str, path: Path) -> Optional[List[Path]]: try: - mod, relroots = self.pluginmanager._rget_with_confmod( - name, path, self.getoption("importmode"), rootpath - ) + mod, relroots = self.pluginmanager._rget_with_confmod(name, path) except KeyError: return None assert mod.__file__ is not None diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 8882486b0..2b30ab913 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -64,7 +64,6 @@ from _pytest.pathlib import bestrelpath from _pytest.scope import _ScopeName from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope -from _pytest.stash import StashKey if TYPE_CHECKING: @@ -148,89 +147,6 @@ def get_scope_node( assert_never(scope) -# Used for storing artificial fixturedefs for direct parametrization. -name2pseudofixturedef_key = StashKey[Dict[str, "FixtureDef[Any]"]]() - - -def add_funcarg_pseudo_fixture_def( - collector: nodes.Collector, metafunc: "Metafunc", fixturemanager: "FixtureManager" -) -> None: - import _pytest.python - - # This function will transform all collected calls to functions - # if they use direct funcargs (i.e. direct parametrization) - # because we want later test execution to be able to rely on - # an existing FixtureDef structure for all arguments. - # XXX we can probably avoid this algorithm if we modify CallSpec2 - # to directly care for creating the fixturedefs within its methods. - if not metafunc._calls[0].funcargs: - # This function call does not have direct parametrization. - return - # Collect funcargs of all callspecs into a list of values. - arg2params: Dict[str, List[object]] = {} - arg2scope: Dict[str, Scope] = {} - for callspec in metafunc._calls: - for argname, argvalue in callspec.funcargs.items(): - assert argname not in callspec.params - callspec.params[argname] = argvalue - arg2params_list = arg2params.setdefault(argname, []) - callspec.indices[argname] = len(arg2params_list) - arg2params_list.append(argvalue) - if argname not in arg2scope: - scope = callspec._arg2scope.get(argname, Scope.Function) - arg2scope[argname] = scope - callspec.funcargs.clear() - - # Register artificial FixtureDef's so that later at test execution - # time we can rely on a proper FixtureDef to exist for fixture setup. - arg2fixturedefs = metafunc._arg2fixturedefs - for argname, valuelist in arg2params.items(): - # If we have a scope that is higher than function, we need - # to make sure we only ever create an according fixturedef on - # a per-scope basis. We thus store and cache the fixturedef on the - # node related to the scope. - scope = arg2scope[argname] - node = None - if scope is not Scope.Function: - node = get_scope_node(collector, scope) - if node is None: - # If used class scope and there is no class, use module-level - # collector (for now). - if scope is Scope.Class: - assert isinstance(collector, _pytest.python.Module) - node = collector - # If used package scope and there is no package, use session - # (for now). - elif scope is Scope.Package: - node = collector.session - else: - assert False, f"Unhandled missing scope: {scope}" - if node is None: - name2pseudofixturedef = None - else: - default: Dict[str, FixtureDef[Any]] = {} - name2pseudofixturedef = node.stash.setdefault( - name2pseudofixturedef_key, default - ) - if name2pseudofixturedef is not None and argname in name2pseudofixturedef: - arg2fixturedefs[argname] = [name2pseudofixturedef[argname]] - else: - fixturedef = FixtureDef( - fixturemanager=fixturemanager, - baseid="", - argname=argname, - func=get_direct_param_fixture_func, - scope=arg2scope[argname], - params=valuelist, - unittest=False, - ids=None, - _ispytest=True, - ) - arg2fixturedefs[argname] = [fixturedef] - if name2pseudofixturedef is not None: - name2pseudofixturedef[argname] = fixturedef - - def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: """Return fixturemarker or None if it doesn't exist or raised exceptions.""" @@ -271,7 +187,7 @@ def get_parametrized_fixture_keys( pass else: cs: CallSpec2 = callspec - # cs.indices.items() is random order of argnames. Need to + # cs.indices is random order of argnames. Need to # sort this so that different calls to # get_parametrized_fixture_keys will be deterministic. for argname in sorted(cs.indices): @@ -282,7 +198,7 @@ def get_parametrized_fixture_keys( if scope is Scope.Session: scoped_item_path = None elif scope is Scope.Package: - scoped_item_path = item.path.parent + scoped_item_path = item.path elif scope is Scope.Module: scoped_item_path = item.path elif scope is Scope.Class: @@ -385,10 +301,6 @@ def reorder_items_atscope( return items_done -def get_direct_param_fixture_func(request: "FixtureRequest") -> Any: - return request.param - - @dataclasses.dataclass(frozen=True) class FuncFixtureInfo: """Fixture-related information for a fixture-requesting item (e.g. test @@ -512,7 +424,7 @@ class FixtureRequest: node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem elif scope is Scope.Package: # FIXME: _fixturedef is not defined on FixtureRequest (this class), - # but on FixtureRequest (a subclass). + # but on SubRequest (a subclass). node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined] else: node = get_scope_node(self._pyfuncitem, scope) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 4c3c9aed4..fd3836736 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -376,7 +376,7 @@ def _in_venv(path: Path) -> bool: def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[bool]: ignore_paths = config._getconftest_pathlist( - "collect_ignore", path=collection_path.parent, rootpath=config.rootpath + "collect_ignore", path=collection_path.parent ) ignore_paths = ignore_paths or [] excludeopt = config.getoption("ignore") @@ -387,7 +387,7 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[boo return True ignore_globs = config._getconftest_pathlist( - "collect_ignore_glob", path=collection_path.parent, rootpath=config.rootpath + "collect_ignore_glob", path=collection_path.parent ) ignore_globs = ignore_globs or [] excludeglobopt = config.getoption("ignore_glob") @@ -551,11 +551,16 @@ class Session(nodes.FSCollector): pm = self.config.pluginmanager # Check if we have the common case of running # hooks with all conftest.py files. - my_conftestmodules = pm._getconftestmodules( + # + # TODO: pytest relies on this call to load non-initial conftests. This + # is incidental. It will be better to load conftests at a more + # well-defined place. + pm._loadconftestmodules( path, self.config.getoption("importmode"), rootpath=self.config.rootpath, ) + my_conftestmodules = pm._getconftestmodules(path) remove_mods = pm._conftest_plugins.difference(my_conftestmodules) if remove_mods: # One or more conftests are not in use at this fspath. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index c0c16d4d0..c3097b863 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -40,7 +40,6 @@ from _pytest._code.code import Traceback from _pytest._io import TerminalWriter from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped -from _pytest.compat import assert_never from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc @@ -59,7 +58,10 @@ from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.deprecated import INSTANCE_COLLECTOR from _pytest.deprecated import NOSE_SUPPORT_METHOD +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FuncFixtureInfo +from _pytest.fixtures import get_scope_node from _pytest.main import Session from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet @@ -77,6 +79,7 @@ from _pytest.pathlib import parts from _pytest.pathlib import visit from _pytest.scope import _ScopeName from _pytest.scope import Scope +from _pytest.stash import StashKey from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestReturnNotNoneWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning @@ -493,13 +496,11 @@ class PyCollector(PyobjMixin, nodes.Collector): if not metafunc._calls: yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: - # Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs. - fm = self.session._fixturemanager - fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) - - # Add_funcarg_pseudo_fixture_def may have shadowed some fixtures - # with direct parametrization, so make sure we update what the - # function really needs. + # Direct parametrizations taking place in module/class-specific + # `metafunc.parametrize` calls may have shadowed some fixtures, so make sure + # we update what the function really needs a.k.a its fixture closure. Note that + # direct parametrizations using `@pytest.mark.parametrize` have already been considered + # into making the closure using `ignore_args` arg to `getfixtureclosure`. fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: @@ -1116,11 +1117,8 @@ class CallSpec2: and stored in item.callspec. """ - # arg name -> arg value which will be passed to the parametrized test - # function (direct parameterization). - funcargs: Dict[str, object] = dataclasses.field(default_factory=dict) - # arg name -> arg value which will be passed to a fixture of the same name - # (indirect parametrization). + # arg name -> arg value which will be passed to a fixture or pseudo-fixture + # of the same name. (indirect or direct parametrization respectively) params: Dict[str, object] = dataclasses.field(default_factory=dict) # arg name -> arg index. indices: Dict[str, int] = dataclasses.field(default_factory=dict) @@ -1134,7 +1132,6 @@ class CallSpec2: def setmulti( self, *, - valtypes: Mapping[str, "Literal['params', 'funcargs']"], argnames: Iterable[str], valset: Iterable[object], id: str, @@ -1142,24 +1139,16 @@ class CallSpec2: scope: Scope, param_index: int, ) -> "CallSpec2": - funcargs = self.funcargs.copy() params = self.params.copy() indices = self.indices.copy() arg2scope = self._arg2scope.copy() for arg, val in zip(argnames, valset): - if arg in params or arg in funcargs: - raise ValueError(f"duplicate {arg!r}") - valtype_for_arg = valtypes[arg] - if valtype_for_arg == "params": - params[arg] = val - elif valtype_for_arg == "funcargs": - funcargs[arg] = val - else: - assert_never(valtype_for_arg) + if arg in params: + raise ValueError(f"duplicate parametrization of {arg!r}") + params[arg] = val indices[arg] = param_index arg2scope[arg] = scope return CallSpec2( - funcargs=funcargs, params=params, indices=indices, _arg2scope=arg2scope, @@ -1178,6 +1167,14 @@ class CallSpec2: return "-".join(self._idlist) +def get_direct_param_fixture_func(request: FixtureRequest) -> Any: + return request.param + + +# Used for storing pseudo fixturedefs for direct parametrization. +name2pseudofixturedef_key = StashKey[Dict[str, FixtureDef[Any]]]() + + @final class Metafunc: """Objects passed to the :hook:`pytest_generate_tests` hook. @@ -1239,8 +1236,9 @@ class Metafunc: during the collection phase. If you need to setup expensive resources see about setting indirect to do it rather than at test setup time. - Can be called multiple times, in which case each call parametrizes all - previous parametrizations, e.g. + Can be called multiple times per test function (but only on different + argument names), in which case each call parametrizes all previous + parametrizations, e.g. :: @@ -1319,8 +1317,6 @@ class Metafunc: self._validate_if_using_arg_names(argnames, indirect) - arg_values_types = self._resolve_arg_value_types(argnames, indirect) - # Use any already (possibly) generated ids with parametrize Marks. if _param_mark and _param_mark._param_ids_from: generated_ids = _param_mark._param_ids_from._param_ids_generated @@ -1335,6 +1331,60 @@ class Metafunc: if _param_mark and _param_mark._param_ids_from and generated_ids is None: object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) + # Add funcargs as fixturedefs to fixtureinfo.arg2fixturedefs by registering + # artificial "pseudo" FixtureDef's so that later at test execution time we can + # rely on a proper FixtureDef to exist for fixture setup. + arg2fixturedefs = self._arg2fixturedefs + node = None + # If we have a scope that is higher than function, we need + # to make sure we only ever create an according fixturedef on + # a per-scope basis. We thus store and cache the fixturedef on the + # node related to the scope. + if scope_ is not Scope.Function: + collector = self.definition.parent + assert collector is not None + node = get_scope_node(collector, scope_) + if node is None: + # If used class scope and there is no class, use module-level + # collector (for now). + if scope_ is Scope.Class: + assert isinstance(collector, _pytest.python.Module) + node = collector + # If used package scope and there is no package, use session + # (for now). + elif scope_ is Scope.Package: + node = collector.session + else: + assert False, f"Unhandled missing scope: {scope}" + if node is None: + name2pseudofixturedef = None + else: + default: Dict[str, FixtureDef[Any]] = {} + name2pseudofixturedef = node.stash.setdefault( + name2pseudofixturedef_key, default + ) + arg_directness = self._resolve_args_directness(argnames, indirect) + for argname in argnames: + if arg_directness[argname] == "indirect": + continue + if name2pseudofixturedef is not None and argname in name2pseudofixturedef: + fixturedef = name2pseudofixturedef[argname] + else: + fixturedef = FixtureDef( + fixturemanager=self.definition.session._fixturemanager, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=scope_, + params=None, + unittest=False, + ids=None, + _ispytest=True, + ) + if name2pseudofixturedef is not None: + name2pseudofixturedef[argname] = fixturedef + arg2fixturedefs[argname] = [fixturedef] + # Create the new calls: if we are parametrize() multiple times (by applying the decorator # more than once) then we accumulate those calls generating the cartesian product # of all calls. @@ -1344,7 +1394,6 @@ class Metafunc: zip(ids, parametersets) ): newcallspec = callspec.setmulti( - valtypes=arg_values_types, argnames=argnames, valset=param_set.values, id=param_id, @@ -1421,28 +1470,30 @@ class Metafunc: return list(itertools.islice(ids, num_ids)) - def _resolve_arg_value_types( + def _resolve_args_directness( self, argnames: Sequence[str], indirect: Union[bool, Sequence[str]], - ) -> Dict[str, "Literal['params', 'funcargs']"]: - """Resolve if each parametrized argument must be considered a - parameter to a fixture or a "funcarg" to the function, based on the - ``indirect`` parameter of the parametrized() call. + ) -> Dict[str, Literal["indirect", "direct"]]: + """Resolve if each parametrized argument must be considered an indirect + parameter to a fixture of the same name, or a direct parameter to the + parametrized function, based on the ``indirect`` parameter of the + parametrized() call. - :param List[str] argnames: List of argument names passed to ``parametrize()``. - :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. - :rtype: Dict[str, str] - A dict mapping each arg name to either: - * "params" if the argname should be the parameter of a fixture of the same name. - * "funcargs" if the argname should be a parameter to the parametrized test function. + :param argnames: + List of argument names passed to ``parametrize()``. + :param indirect: + Same as the ``indirect`` parameter of ``parametrize()``. + :returns + A dict mapping each arg name to either "indirect" or "direct". """ + arg_directness: Dict[str, Literal["indirect", "direct"]] if isinstance(indirect, bool): - valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys( - argnames, "params" if indirect else "funcargs" + arg_directness = dict.fromkeys( + argnames, "indirect" if indirect else "direct" ) elif isinstance(indirect, Sequence): - valtypes = dict.fromkeys(argnames, "funcargs") + arg_directness = dict.fromkeys(argnames, "direct") for arg in indirect: if arg not in argnames: fail( @@ -1451,7 +1502,7 @@ class Metafunc: ), pytrace=False, ) - valtypes[arg] = "params" + arg_directness[arg] = "indirect" else: fail( "In {func}: expected Sequence or boolean for indirect, got {type}".format( @@ -1459,7 +1510,7 @@ class Metafunc: ), pytrace=False, ) - return valtypes + return arg_directness def _validate_if_using_arg_names( self, @@ -1516,7 +1567,7 @@ def _find_parametrized_scope( if all_arguments_are_fixtures: fixturedefs = arg2fixturedefs or {} used_scopes = [ - fixturedef[0]._scope + fixturedef[-1]._scope for name, fixturedef in fixturedefs.items() if name in argnames ] @@ -1682,7 +1733,7 @@ class Function(PyobjMixin, nodes.Item): :param config: The pytest Config object. :param callspec: - If given, this is function has been parametrized and the callspec contains + If given, this function has been parametrized and the callspec contains meta information about the parametrization. :param callobj: If given, the object which will be called when the Function is invoked, diff --git a/testing/_py/test_local.py b/testing/_py/test_local.py index 895066a9f..91b14aa2e 100644 --- a/testing/_py/test_local.py +++ b/testing/_py/test_local.py @@ -1573,4 +1573,4 @@ class TestBinaryAndTextMethods: x.write_text(part, "ascii") s = x.read_text("ascii") assert s == part - assert type(s) == type(part) + assert type(s) is type(part) diff --git a/testing/plugins_integration/requirements.txt b/testing/plugins_integration/requirements.txt index cf580b7cc..d46300fe0 100644 --- a/testing/plugins_integration/requirements.txt +++ b/testing/plugins_integration/requirements.txt @@ -1,5 +1,5 @@ anyio[curio,trio]==3.7.1 -django==4.2.3 +django==4.2.4 pytest-asyncio==0.21.1 pytest-bdd==6.1.1 pytest-cov==4.1.0 diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 1e05bf4c4..c32aa04a6 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2104,9 +2104,7 @@ class TestAutouseManagement: reprec = pytester.inline_run("-v", "-s", "--confcutdir", pytester.path) reprec.assertoutcome(passed=8) config = reprec.getcalls("pytest_unconfigure")[0].config - values = config.pluginmanager._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path - )[0].values + values = config.pluginmanager._getconftestmodules(p)[0].values assert values == ["fin_a1", "fin_a2", "fin_b1", "fin_b2"] * 2 def test_scope_ordering(self, pytester: Pytester) -> None: diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index bb4ae9d9a..4ee9e32d6 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -23,6 +23,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 Function from _pytest.python import IdMaker from _pytest.scope import Scope @@ -33,11 +34,19 @@ class TestMetafunc: # on the funcarg level, so we don't need a full blown # initialization. class FuncFixtureInfoMock: - name2fixturedefs = None + name2fixturedefs: Dict[str, List[fixtures.FixtureDef[object]]] = {} def __init__(self, names): self.names_closure = names + @dataclasses.dataclass + class FixtureManagerMock: + config: Any + + @dataclasses.dataclass + class SessionMock: + _fixturemanager: FixtureManagerMock + @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): _nodeid: str @@ -46,6 +55,8 @@ class TestMetafunc: names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") + definition._fixtureinfo = fixtureinfo + definition.session = SessionMock(FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) def test_no_funcargs(self) -> None: @@ -98,7 +109,7 @@ class TestMetafunc: # When the input is an iterator, only len(args) are taken, # so the bad Exc isn't reached. metafunc.parametrize("x", [1, 2], ids=gen()) # type: ignore[arg-type] - assert [(x.funcargs, x.id) for x in metafunc._calls] == [ + assert [(x.params, x.id) for x in metafunc._calls] == [ ({"x": 1}, "0"), ({"x": 2}, "2"), ] @@ -151,6 +162,7 @@ class TestMetafunc: module_fix=[DummyFixtureDef(Scope.Module)], class_fix=[DummyFixtureDef(Scope.Class)], func_fix=[DummyFixtureDef(Scope.Function)], + mixed_fix=[DummyFixtureDef(Scope.Module), DummyFixtureDef(Scope.Class)], ), ) @@ -187,6 +199,7 @@ class TestMetafunc: ) == Scope.Module ) + assert find_scope(["mixed_fix"], indirect=True) == Scope.Class def test_parametrize_and_id(self) -> None: def func(x, y): @@ -712,8 +725,6 @@ class TestMetafunc: metafunc.parametrize("x", [1], indirect=True) metafunc.parametrize("y", [2, 3], indirect=True) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == {} - assert metafunc._calls[1].funcargs == {} assert metafunc._calls[0].params == dict(x=1, y=2) assert metafunc._calls[1].params == dict(x=1, y=3) @@ -725,8 +736,10 @@ class TestMetafunc: metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x"]) - assert metafunc._calls[0].funcargs == dict(y="b") - assert metafunc._calls[0].params == dict(x="a") + assert metafunc._calls[0].params == dict(x="a", y="b") + # Since `y` is a direct parameter, its pseudo-fixture would + # be registered. + assert list(metafunc._arg2fixturedefs.keys()) == ["y"] def test_parametrize_indirect_list_all(self) -> None: """#714""" @@ -736,8 +749,8 @@ class TestMetafunc: metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x", "y"]) - assert metafunc._calls[0].funcargs == {} assert metafunc._calls[0].params == dict(x="a", y="b") + assert list(metafunc._arg2fixturedefs.keys()) == [] def test_parametrize_indirect_list_empty(self) -> None: """#714""" @@ -747,8 +760,8 @@ class TestMetafunc: metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=[]) - assert metafunc._calls[0].funcargs == dict(x="a", y="b") - assert metafunc._calls[0].params == {} + assert metafunc._calls[0].params == dict(x="a", y="b") + assert list(metafunc._arg2fixturedefs.keys()) == ["x", "y"] def test_parametrize_indirect_wrong_type(self) -> None: def func(x, y): @@ -942,9 +955,9 @@ class TestMetafunc: metafunc = self.Metafunc(lambda x: None) metafunc.parametrize("x", [1, 2]) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == dict(x=1) + assert metafunc._calls[0].params == dict(x=1) assert metafunc._calls[0].id == "1" - assert metafunc._calls[1].funcargs == dict(x=2) + assert metafunc._calls[1].params == dict(x=2) assert metafunc._calls[1].id == "2" def test_parametrize_onearg_indirect(self) -> None: @@ -959,11 +972,45 @@ class TestMetafunc: metafunc = self.Metafunc(lambda x, y: None) metafunc.parametrize(("x", "y"), [(1, 2), (3, 4)]) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == dict(x=1, y=2) + assert metafunc._calls[0].params == dict(x=1, y=2) assert metafunc._calls[0].id == "1-2" - assert metafunc._calls[1].funcargs == dict(x=3, y=4) + assert metafunc._calls[1].params == dict(x=3, y=4) assert metafunc._calls[1].id == "3-4" + def test_high_scoped_parametrize_reordering(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("arg2", [3, 4]) + @pytest.mark.parametrize("arg1", [0, 1, 2], scope='module') + def test1(arg1, arg2): + pass + + def test2(): + pass + + @pytest.mark.parametrize("arg1", [0, 1, 2], scope='module') + def test3(arg1): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.re_match_lines( + [ + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + ] + ) + def test_parametrize_multiple_times(self, pytester: Pytester) -> None: pytester.makepyfile( """ @@ -1503,6 +1550,98 @@ class TestMetafuncFunctional: result = pytester.runpytest() assert result.ret == 0 + def test_parametrize_module_level_test_with_class_scope( + self, pytester: Pytester + ) -> None: + """ + Test that a class-scoped parametrization without a corresponding `Class` + gets module scope, i.e. we only create a single FixtureDef for it per module. + """ + module = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [0, 1], scope="class") + def test_1(x): + pass + + @pytest.mark.parametrize("x", [1, 2], scope="module") + def test_2(x): + pass + """ + ) + test_1_0, _, test_2_0, _ = pytester.genitems((pytester.getmodulecol(module),)) + + assert isinstance(test_1_0, Function) + assert test_1_0.name == "test_1[0]" + test_1_fixture_x = test_1_0._fixtureinfo.name2fixturedefs["x"][-1] + + assert isinstance(test_2_0, Function) + assert test_2_0.name == "test_2[1]" + test_2_fixture_x = test_2_0._fixtureinfo.name2fixturedefs["x"][-1] + + assert test_1_fixture_x is test_2_fixture_x + + def test_reordering_with_scopeless_and_just_indirect_parametrization( + self, pytester: Pytester + ) -> None: + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(scope="package") + def fixture1(): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module") + def fixture0(): + pass + + @pytest.fixture(scope="module") + def fixture1(fixture0): + pass + + @pytest.mark.parametrize("fixture1", [0], indirect=True) + def test_0(fixture1): + pass + + @pytest.fixture(scope="module") + def fixture(): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_1(fixture): + pass + + def test_2(): + pass + + class Test: + @pytest.fixture(scope="class") + def fixture(self, fixture): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_3(self, fixture): + pass + """ + ) + result = pytester.runpytest("-v") + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "*test_0*", + "*test_1*", + "*test_2*", + "*test_3*", + ] + ) + class TestMetafuncFunctionalAuto: """Tests related to automatically find out the correct scope for diff --git a/testing/test_config.py b/testing/test_config.py index 43561000c..04161f238 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -642,18 +642,11 @@ class TestConfigAPI: p = tmp_path.joinpath("conftest.py") p.write_text(f"mylist = {['.', str(somepath)]}", encoding="utf-8") config = pytester.parseconfigure(p) - assert ( - config._getconftest_pathlist("notexist", path=tmp_path, rootpath=tmp_path) - is None - ) - pl = ( - config._getconftest_pathlist("mylist", path=tmp_path, rootpath=tmp_path) - or [] - ) - print(pl) - assert len(pl) == 2 - assert pl[0] == tmp_path - assert pl[1] == somepath + assert config._getconftest_pathlist("notexist", path=tmp_path) is None + assert config._getconftest_pathlist("mylist", path=tmp_path) == [ + tmp_path, + somepath, + ] @pytest.mark.parametrize("maybe_type", ["not passed", "None", '"string"']) def test_addini(self, pytester: Pytester, maybe_type: str) -> None: diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 427831507..cfc2d577b 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -62,28 +62,22 @@ class TestConftestValueAccessGlobal: def test_basic_init(self, basedir: Path) -> None: conftest = PytestPluginManager() p = basedir / "adir" - assert ( - conftest._rget_with_confmod("a", p, importmode="prepend", rootpath=basedir)[ - 1 - ] - == 1 - ) + conftest._loadconftestmodules(p, importmode="prepend", rootpath=basedir) + assert conftest._rget_with_confmod("a", p)[1] == 1 def test_immediate_initialiation_and_incremental_are_the_same( self, basedir: Path ) -> None: conftest = PytestPluginManager() assert not len(conftest._dirpath2confmods) - conftest._getconftestmodules( - basedir, importmode="prepend", rootpath=Path(basedir) - ) + conftest._loadconftestmodules(basedir, importmode="prepend", rootpath=basedir) snap1 = len(conftest._dirpath2confmods) assert snap1 == 1 - conftest._getconftestmodules( + conftest._loadconftestmodules( basedir / "adir", importmode="prepend", rootpath=basedir ) assert len(conftest._dirpath2confmods) == snap1 + 1 - conftest._getconftestmodules( + conftest._loadconftestmodules( basedir / "b", importmode="prepend", rootpath=basedir ) assert len(conftest._dirpath2confmods) == snap1 + 2 @@ -91,33 +85,23 @@ class TestConftestValueAccessGlobal: def test_value_access_not_existing(self, basedir: Path) -> None: conftest = ConftestWithSetinitial(basedir) with pytest.raises(KeyError): - conftest._rget_with_confmod( - "a", basedir, importmode="prepend", rootpath=Path(basedir) - ) + conftest._rget_with_confmod("a", basedir) def test_value_access_by_path(self, basedir: Path) -> None: conftest = ConftestWithSetinitial(basedir) adir = basedir / "adir" - assert ( - conftest._rget_with_confmod( - "a", adir, importmode="prepend", rootpath=basedir - )[1] - == 1 - ) - assert ( - conftest._rget_with_confmod( - "a", adir / "b", importmode="prepend", rootpath=basedir - )[1] - == 1.5 + conftest._loadconftestmodules(adir, importmode="prepend", rootpath=basedir) + assert conftest._rget_with_confmod("a", adir)[1] == 1 + conftest._loadconftestmodules( + adir / "b", importmode="prepend", rootpath=basedir ) + assert conftest._rget_with_confmod("a", adir / "b")[1] == 1.5 def test_value_access_with_confmod(self, basedir: Path) -> None: startdir = basedir / "adir" / "b" startdir.joinpath("xx").mkdir() conftest = ConftestWithSetinitial(startdir) - mod, value = conftest._rget_with_confmod( - "a", startdir, importmode="prepend", rootpath=Path(basedir) - ) + mod, value = conftest._rget_with_confmod("a", startdir) assert value == 1.5 assert mod.__file__ is not None path = Path(mod.__file__) @@ -143,9 +127,7 @@ def test_doubledash_considered(pytester: Pytester) -> None: conf.joinpath("conftest.py").touch() conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.name, conf.name]) - values = conftest._getconftestmodules( - conf, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(conf) assert len(values) == 1 @@ -192,26 +174,22 @@ def test_conftestcutdir(pytester: Pytester) -> None: p = pytester.mkdir("x") conftest = PytestPluginManager() conftest_setinitial(conftest, [pytester.path], confcutdir=p) - values = conftest._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path - ) + conftest._loadconftestmodules(p, importmode="prepend", rootpath=pytester.path) + values = conftest._getconftestmodules(p) assert len(values) == 0 - values = conftest._getconftestmodules( + conftest._loadconftestmodules( conf.parent, importmode="prepend", rootpath=pytester.path ) + values = conftest._getconftestmodules(conf.parent) assert len(values) == 0 assert not conftest.has_plugin(str(conf)) # but we can still import a conftest directly conftest._importconftest(conf, importmode="prepend", rootpath=pytester.path) - values = conftest._getconftestmodules( - conf.parent, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(conf.parent) assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) # and all sub paths get updated properly - values = conftest._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(p) assert len(values) == 1 assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) @@ -221,9 +199,7 @@ def test_conftestcutdir_inplace_considered(pytester: Pytester) -> None: conf = pytester.makeconftest("") conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.parent], confcutdir=conf.parent) - values = conftest._getconftestmodules( - conf.parent, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(conf.parent) assert len(values) == 1 assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) @@ -433,10 +409,8 @@ def test_conftest_import_order(pytester: Pytester, monkeypatch: MonkeyPatch) -> conftest = PytestPluginManager() conftest._confcutdir = pytester.path monkeypatch.setattr(conftest, "_importconftest", impct) - mods = cast( - List[Path], - conftest._getconftestmodules(sub, importmode="prepend", rootpath=pytester.path), - ) + conftest._loadconftestmodules(sub, importmode="prepend", rootpath=pytester.path) + mods = cast(List[Path], conftest._getconftestmodules(sub)) expected = [ct1, ct2] assert mods == expected