diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index d4aaba1ec23..88efa93b7b2 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -808,6 +808,18 @@ 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. + + :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 c5b4ff39e85..92916eb8bef 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 @@ -101,6 +102,14 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, "platform": platform, "config": item.config, } + for dictionary in 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..a18d29c044c 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,127 @@ 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): + 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 + """ + ) + item = testdir.getitem( + """ + import pytest + + def test_func(): + assert True + """ + ) + with pytest.raises(ValueError): + pytest_runtest_setup(item) + class TestXFail: @pytest.mark.parametrize("strict", [True, False]) @@ -569,6 +691,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_skipif_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):