Skip to content

Commit 95e0e19

Browse files
authored
Merge pull request #8124 from bluetech/s0undt3ch-feature/skip-context-hook
Add `pytest_markeval_namespace` hook.
2 parents cf1051c + b16c091 commit 95e0e19

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-0
lines changed

changelog/7695.feature.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
A new hook was added, `pytest_markeval_namespace` which should return a dictionary.
2+
This dictionary will be used to augment the "global" variables available to evaluate skipif/xfail/xpass markers.
3+
4+
Pseudo example
5+
6+
``conftest.py``:
7+
8+
.. code-block:: python
9+
10+
def pytest_markeval_namespace():
11+
return {"color": "red"}
12+
13+
``test_func.py``:
14+
15+
.. code-block:: python
16+
17+
@pytest.mark.skipif("color == 'blue'", reason="Color is not red")
18+
def test_func():
19+
assert False

src/_pytest/hookspec.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,27 @@ def pytest_warning_recorded(
808808
"""
809809

810810

811+
# -------------------------------------------------------------------------
812+
# Hooks for influencing skipping
813+
# -------------------------------------------------------------------------
814+
815+
816+
def pytest_markeval_namespace(config: "Config") -> Dict[str, Any]:
817+
"""Called when constructing the globals dictionary used for
818+
evaluating string conditions in xfail/skipif markers.
819+
820+
This is useful when the condition for a marker requires
821+
objects that are expensive or impossible to obtain during
822+
collection time, which is required by normal boolean
823+
conditions.
824+
825+
.. versionadded:: 6.2
826+
827+
:param _pytest.config.Config config: The pytest config object.
828+
:returns: A dictionary of additional globals to add.
829+
"""
830+
831+
811832
# -------------------------------------------------------------------------
812833
# error handling and internal debugging hooks
813834
# -------------------------------------------------------------------------

src/_pytest/skipping.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import platform
44
import sys
55
import traceback
6+
from collections.abc import Mapping
67
from typing import Generator
78
from typing import Optional
89
from typing import Tuple
@@ -98,6 +99,16 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool,
9899
"platform": platform,
99100
"config": item.config,
100101
}
102+
for dictionary in reversed(
103+
item.ihook.pytest_markeval_namespace(config=item.config)
104+
):
105+
if not isinstance(dictionary, Mapping):
106+
raise ValueError(
107+
"pytest_markeval_namespace() needs to return a dict, got {!r}".format(
108+
dictionary
109+
)
110+
)
111+
globals_.update(dictionary)
101112
if hasattr(item, "obj"):
102113
globals_.update(item.obj.__globals__) # type: ignore[attr-defined]
103114
try:

testing/test_skipping.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
import textwrap
23

34
import pytest
45
from _pytest.pytester import Pytester
@@ -155,6 +156,136 @@ def test_func(self):
155156
assert skipped
156157
assert skipped.reason == "condition: config._hackxyz"
157158

159+
def test_skipif_markeval_namespace(self, pytester: Pytester) -> None:
160+
pytester.makeconftest(
161+
"""
162+
import pytest
163+
164+
def pytest_markeval_namespace():
165+
return {"color": "green"}
166+
"""
167+
)
168+
p = pytester.makepyfile(
169+
"""
170+
import pytest
171+
172+
@pytest.mark.skipif("color == 'green'")
173+
def test_1():
174+
assert True
175+
176+
@pytest.mark.skipif("color == 'red'")
177+
def test_2():
178+
assert True
179+
"""
180+
)
181+
res = pytester.runpytest(p)
182+
assert res.ret == 0
183+
res.stdout.fnmatch_lines(["*1 skipped*"])
184+
res.stdout.fnmatch_lines(["*1 passed*"])
185+
186+
def test_skipif_markeval_namespace_multiple(self, pytester: Pytester) -> None:
187+
"""Keys defined by ``pytest_markeval_namespace()`` in nested plugins override top-level ones."""
188+
root = pytester.mkdir("root")
189+
root.joinpath("__init__.py").touch()
190+
root.joinpath("conftest.py").write_text(
191+
textwrap.dedent(
192+
"""\
193+
import pytest
194+
195+
def pytest_markeval_namespace():
196+
return {"arg": "root"}
197+
"""
198+
)
199+
)
200+
root.joinpath("test_root.py").write_text(
201+
textwrap.dedent(
202+
"""\
203+
import pytest
204+
205+
@pytest.mark.skipif("arg == 'root'")
206+
def test_root():
207+
assert False
208+
"""
209+
)
210+
)
211+
foo = root.joinpath("foo")
212+
foo.mkdir()
213+
foo.joinpath("__init__.py").touch()
214+
foo.joinpath("conftest.py").write_text(
215+
textwrap.dedent(
216+
"""\
217+
import pytest
218+
219+
def pytest_markeval_namespace():
220+
return {"arg": "foo"}
221+
"""
222+
)
223+
)
224+
foo.joinpath("test_foo.py").write_text(
225+
textwrap.dedent(
226+
"""\
227+
import pytest
228+
229+
@pytest.mark.skipif("arg == 'foo'")
230+
def test_foo():
231+
assert False
232+
"""
233+
)
234+
)
235+
bar = root.joinpath("bar")
236+
bar.mkdir()
237+
bar.joinpath("__init__.py").touch()
238+
bar.joinpath("conftest.py").write_text(
239+
textwrap.dedent(
240+
"""\
241+
import pytest
242+
243+
def pytest_markeval_namespace():
244+
return {"arg": "bar"}
245+
"""
246+
)
247+
)
248+
bar.joinpath("test_bar.py").write_text(
249+
textwrap.dedent(
250+
"""\
251+
import pytest
252+
253+
@pytest.mark.skipif("arg == 'bar'")
254+
def test_bar():
255+
assert False
256+
"""
257+
)
258+
)
259+
260+
reprec = pytester.inline_run("-vs", "--capture=no")
261+
reprec.assertoutcome(skipped=3)
262+
263+
def test_skipif_markeval_namespace_ValueError(self, pytester: Pytester) -> None:
264+
pytester.makeconftest(
265+
"""
266+
import pytest
267+
268+
def pytest_markeval_namespace():
269+
return True
270+
"""
271+
)
272+
p = pytester.makepyfile(
273+
"""
274+
import pytest
275+
276+
@pytest.mark.skipif("color == 'green'")
277+
def test_1():
278+
assert True
279+
"""
280+
)
281+
res = pytester.runpytest(p)
282+
assert res.ret == 1
283+
res.stdout.fnmatch_lines(
284+
[
285+
"*ValueError: pytest_markeval_namespace() needs to return a dict, got True*"
286+
]
287+
)
288+
158289

159290
class TestXFail:
160291
@pytest.mark.parametrize("strict", [True, False])
@@ -577,6 +708,33 @@ def test_foo():
577708
result.stdout.fnmatch_lines(["*1 failed*" if strict else "*1 xpassed*"])
578709
assert result.ret == (1 if strict else 0)
579710

711+
def test_xfail_markeval_namespace(self, pytester: Pytester) -> None:
712+
pytester.makeconftest(
713+
"""
714+
import pytest
715+
716+
def pytest_markeval_namespace():
717+
return {"color": "green"}
718+
"""
719+
)
720+
p = pytester.makepyfile(
721+
"""
722+
import pytest
723+
724+
@pytest.mark.xfail("color == 'green'")
725+
def test_1():
726+
assert False
727+
728+
@pytest.mark.xfail("color == 'red'")
729+
def test_2():
730+
assert False
731+
"""
732+
)
733+
res = pytester.runpytest(p)
734+
assert res.ret == 1
735+
res.stdout.fnmatch_lines(["*1 failed*"])
736+
res.stdout.fnmatch_lines(["*1 xfailed*"])
737+
580738

581739
class TestXFailwithSetupTeardown:
582740
def test_failing_setup_issue9(self, pytester: Pytester) -> None:

0 commit comments

Comments
 (0)