diff --git a/changelog/7695.feature.rst b/changelog/7695.feature.rst new file mode 100644 index 00000000000..ec8632fc82a --- /dev/null +++ b/changelog/7695.feature.rst @@ -0,0 +1,19 @@ +A new hook was added, `pytest_markeval_namespace` which should return a dictionary. +This dictionary will be used to augment the "global" variables available to evaluate skipif/xfail/xpass markers. + +Pseudo example + +``conftest.py``: + +.. code-block:: python + + def pytest_markeval_namespace(): + return {"color": "red"} + +``test_func.py``: + +.. code-block:: python + + @pytest.mark.skipif("color == 'blue'", reason="Color is not red") + def test_func(): + assert False diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index aa0b5cef4a6..d1d972d655d 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -808,6 +808,25 @@ def pytest_warning_recorded( """ +# ------------------------------------------------------------------------- +# Hooks for influencing skipping +# ------------------------------------------------------------------------- +def pytest_markeval_namespace(config: "Config") -> Dict[str, Any]: + """Called when constructing the globals dictionary used for + evaluating string conditions in xfail/skipif markers. + + This is useful when the condition for a marker requires + objects that are expensive or impossible to obtain during + collection time, which is required by normal boolean + conditions. + + .. versionadded:: 6.2 + + :param _pytest.config.Config config: The pytest config object. + :returns: A dictionary of additional globals to add. + """ + + # ------------------------------------------------------------------------- # error handling and internal debugging hooks # ------------------------------------------------------------------------- diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index cc505fdd7c2..9b8e34de9ed 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,6 +3,7 @@ import platform import sys import traceback +from collections.abc import Mapping from typing import Generator from typing import Optional from typing import Tuple @@ -98,6 +99,16 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, "platform": platform, "config": item.config, } + for dictionary in reversed( + item.ihook.pytest_markeval_namespace(config=item.config) + ): + if not isinstance(dictionary, Mapping): + raise ValueError( + "pytest_markeval_namespace() needs to return a dict, got {!r}".format( + dictionary + ) + ) + globals_.update(dictionary) if hasattr(item, "obj"): globals_.update(item.obj.__globals__) # type: ignore[attr-defined] try: diff --git a/testing/test_skipping.py b/testing/test_skipping.py index b32d2267d21..ea1d06fc54d 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1,4 +1,5 @@ import sys +import textwrap import pytest from _pytest.pytester import Testdir @@ -153,6 +154,134 @@ def test_func(self): assert skipped assert skipped.reason == "condition: config._hackxyz" + def test_skipif_markeval_namespace(self, testdir): + testdir.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = testdir.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + + @pytest.mark.skipif("color == 'red'") + def test_2(): + assert True + """ + ) + res = testdir.runpytest(p) + assert res.ret == 0 + res.stdout.fnmatch_lines(["*1 skipped*"]) + res.stdout.fnmatch_lines(["*1 passed*"]) + + def test_skipif_markeval_namespace_multiple(self, testdir): + """Keys defined by ``pytest_markeval_namespace()`` in nested plugins override top-level ones.""" + root = testdir.mkdir("root") + root.ensure("__init__.py") + root.join("conftest.py").write( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "root"} + """ + ) + ) + root.join("test_root.py").write( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'root'") + def test_root(): + assert False + """ + ) + ) + foo = root.mkdir("foo") + foo.ensure("__init__.py") + foo.join("conftest.py").write( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "foo"} + """ + ) + ) + foo.join("test_foo.py").write( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'foo'") + def test_foo(): + assert False + """ + ) + ) + bar = root.mkdir("bar") + bar.ensure("__init__.py") + bar.join("conftest.py").write( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "bar"} + """ + ) + ) + bar.join("test_bar.py").write( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'bar'") + def test_bar(): + assert False + """ + ) + ) + + reprec = testdir.inline_run("-vs", "--capture=no") + reprec.assertoutcome(skipped=3) + + def test_skipif_markeval_namespace_ValueError(self, testdir): + testdir.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return True + """ + ) + p = testdir.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + """ + ) + res = testdir.runpytest(p) + assert res.ret == 1 + res.stdout.fnmatch_lines( + [ + "*ValueError: pytest_markeval_namespace() needs to return a dict, got True*" + ] + ) + class TestXFail: @pytest.mark.parametrize("strict", [True, False]) @@ -569,6 +698,33 @@ def test_foo(): result.stdout.fnmatch_lines(["*1 failed*" if strict else "*1 xpassed*"]) assert result.ret == (1 if strict else 0) + def test_xfail_markeval_namespace(self, testdir): + testdir.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = testdir.makepyfile( + """ + import pytest + + @pytest.mark.xfail("color == 'green'") + def test_1(): + assert False + + @pytest.mark.xfail("color == 'red'") + def test_2(): + assert False + """ + ) + res = testdir.runpytest(p) + assert res.ret == 1 + res.stdout.fnmatch_lines(["*1 failed*"]) + res.stdout.fnmatch_lines(["*1 xfailed*"]) + class TestXFailwithSetupTeardown: def test_failing_setup_issue9(self, testdir):