Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pytest_markeval_namespace hook. #8124

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 19 additions & 0 deletions 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
21 changes: 21 additions & 0 deletions src/_pytest/hookspec.py
Expand Up @@ -808,6 +808,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
# -------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions src/_pytest/skipping.py
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
158 changes: 158 additions & 0 deletions testing/test_skipping.py
@@ -1,4 +1,5 @@
import sys
import textwrap

import pytest
from _pytest.pytester import Pytester
Expand Down Expand Up @@ -155,6 +156,136 @@ def test_func(self):
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])
Expand Down Expand Up @@ -577,6 +708,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, 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:
Expand Down