diff options
-rw-r--r-- | changelog/7695.feature.rst | 19 | ||||
-rw-r--r-- | src/_pytest/hookspec.py | 21 | ||||
-rw-r--r-- | src/_pytest/skipping.py | 11 | ||||
-rw-r--r-- | testing/test_skipping.py | 158 |
4 files changed, 209 insertions, 0 deletions
diff --git a/changelog/7695.feature.rst b/changelog/7695.feature.rst new file mode 100644 index 000000000..ec8632fc8 --- /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 33ca782cf..e499b742c 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -809,6 +809,27 @@ 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 afc3610eb..9aacfecee 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,6 +3,7 @@ import os 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 cfc0cdbca..fc66eb18e 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 Pytester @@ -155,6 +156,136 @@ class TestEvaluation: assert skipped assert skipped.reason == "condition: config._hackxyz" + def test_skipif_markeval_namespace(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + + @pytest.mark.skipif("color == 'red'") + def test_2(): + assert True + """ + ) + res = pytester.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, pytester: Pytester) -> None: + """Keys defined by ``pytest_markeval_namespace()`` in nested plugins override top-level ones.""" + root = pytester.mkdir("root") + root.joinpath("__init__.py").touch() + root.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "root"} + """ + ) + ) + root.joinpath("test_root.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'root'") + def test_root(): + assert False + """ + ) + ) + foo = root.joinpath("foo") + foo.mkdir() + foo.joinpath("__init__.py").touch() + foo.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "foo"} + """ + ) + ) + foo.joinpath("test_foo.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'foo'") + def test_foo(): + assert False + """ + ) + ) + bar = root.joinpath("bar") + bar.mkdir() + bar.joinpath("__init__.py").touch() + bar.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "bar"} + """ + ) + ) + bar.joinpath("test_bar.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'bar'") + def test_bar(): + assert False + """ + ) + ) + + reprec = pytester.inline_run("-vs", "--capture=no") + reprec.assertoutcome(skipped=3) + + def test_skipif_markeval_namespace_ValueError(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return True + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + """ + ) + res = pytester.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]) @@ -577,6 +708,33 @@ class TestXFail: 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, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.xfail("color == 'green'") + def test_1(): + assert False + + @pytest.mark.xfail("color == 'red'") + def test_2(): + assert False + """ + ) + res = pytester.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, pytester: Pytester) -> None: |