Skip to content

Commit f87cc2b

Browse files
committed
Add public API function pytest.match_markexpr for marker expression evaluation
- Introduced match_markexpr to match marker expressions against markers or pytest Items. - Updated documentation to reflect the new function. - Added comprehensive tests to ensure functionality and error handling. Closes #13737
1 parent 09a9c6a commit f87cc2b

File tree

7 files changed

+160
-1
lines changed

7 files changed

+160
-1
lines changed

changelog/13759.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added public API function `pytest.match_markexpr` to match marker expressions against a set of markers or a pytest Item.
2+
This function is useful for plugins and test code that need to evaluate marker expressions without relying on internal APIs.
3+
Insuring that the public API is properly tested and documented.

doc/en/example/markers.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,3 +757,28 @@ or to select both "event" and "interface" tests:
757757
FAILED test_module.py::test_interface_complex - assert 0
758758
FAILED test_module.py::test_event_simple - assert 0
759759
===================== 3 failed, 1 deselected in 0.12s ======================
760+
761+
.. _match_markexpr:
762+
763+
Checking expression matches against markers
764+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
765+
766+
Evaluate a marker expression against a pytest Item or a set of marker names.
767+
.. versionadded:: 8.4
768+
769+
.. code-block:: python
770+
771+
import pytest
772+
773+
774+
def test_example():
775+
item = ... # some pytest Item
776+
assert pytest.match_markexpr("smoke and not slow", item)
777+
778+
779+
def test_example2():
780+
marks = ["smoke", "fast"]
781+
assert pytest.match_markexpr("smoke and not slow", marks)
782+
783+
This function is useful for plugins and test code that need to evaluate marker expressions
784+
without relying on internal APIs.

doc/en/how-to/mark.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,31 @@ enforce this validation in your project by adding ``--strict-markers`` to ``addo
9191
markers =
9292
slow: marks tests as slow (deselect with '-m "not slow"')
9393
serial
94+
95+
96+
Check if marks match an expression without test collection
97+
----------------------------------------------------------
98+
99+
You can check if a set of marks or a pytest Item matches a marker expression
100+
using the public API function :func:`pytest.match_markexpr`.
101+
102+
.. code-block:: python
103+
104+
import pytest
105+
106+
107+
def test_example():
108+
item = ... # some pytest Item
109+
assert pytest.match_markexpr("smoke and not slow", item)
110+
111+
112+
def test_example2():
113+
marks = ["smoke", "fast"]
114+
assert pytest.match_markexpr("smoke and not slow", marks)
115+
116+
117+
def test_example3(requests):
118+
assert not pytest.match_markexpr("smoke and not slow", requests.node)
119+
120+
This function is useful for plugins and test code that need to evaluate marker expressions
121+
without relying on internal APIs.

doc/en/reference/reference.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,19 @@ pytest.warns
148148
:with:
149149

150150
pytest.freeze_includes
151-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
151+
~~~~~~~~~~~~~~~~~~~~~~
152152

153153
**Tutorial**: :ref:`freezing-pytest`
154154

155155
.. autofunction:: pytest.freeze_includes
156156

157+
pytest.match_markexpr
158+
~~~~~~~~~~~~~~~~~~~~~
159+
160+
**Tutorial**: :ref:`match_markexpr`
161+
162+
.. autofunction:: pytest.match_markexpr
163+
157164
.. _`marks ref`:
158165

159166
Marks
@@ -289,6 +296,7 @@ Marks a test function as *expected to fail*.
289296
Defaults to :confval:`xfail_strict`, which is ``False`` by default.
290297

291298

299+
292300
Custom marks
293301
~~~~~~~~~~~~
294302

src/_pytest/mark/__init__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"MarkGenerator",
4242
"ParameterSet",
4343
"get_empty_parameterset_mark",
44+
"match_markexpr",
4445
]
4546

4647

@@ -298,3 +299,42 @@ def pytest_configure(config: Config) -> None:
298299

299300
def pytest_unconfigure(config: Config) -> None:
300301
MARK_GEN._config = config.stash.get(old_mark_config_key, None)
302+
303+
304+
def match_markexpr(expr: str, mark: str | Iterable[str] | object) -> bool:
305+
"""
306+
Match a marker expression against a marker name, an iterable of marker names,
307+
or an Item-like object.
308+
309+
:param expr:
310+
The marker expression to evaluate.
311+
:param mark:
312+
A marker name string, an iterable of marker names, or an Item-like object.
313+
:return:
314+
True if the expression matches, False otherwise.
315+
:raises UsageError:
316+
If the target type is unsupported.
317+
318+
"""
319+
try:
320+
compiled = Expression.compile(expr)
321+
except ParseError as e:
322+
raise e
323+
324+
# Build an iterable of Mark objects for the matcher
325+
if isinstance(mark, object) and hasattr(mark, "iter_markers"):
326+
markers_iter = mark.iter_markers()
327+
elif isinstance(mark, str):
328+
markers_iter = (Mark(mark, (), {}, _ispytest=True),)
329+
elif isinstance(mark, Iterable) and not isinstance(mark, (str, bytes, bytearray)):
330+
markers_iter = (Mark(name, (), {}, _ispytest=True) for name in mark)
331+
332+
else:
333+
raise UsageError(
334+
"target must be an Item-like object, a marker name string, "
335+
"or an iterable of marker names"
336+
)
337+
338+
# MarkMatcher is defined in this module (_pytest.mark)
339+
matcher = MarkMatcher.from_markers(markers_iter)
340+
return compiled.evaluate(matcher)

src/pytest/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from _pytest.mark import MARK_GEN as mark
3939
from _pytest.mark import MarkDecorator
4040
from _pytest.mark import MarkGenerator
41+
from _pytest.mark import match_markexpr
4142
from _pytest.mark import param
4243
from _pytest.monkeypatch import MonkeyPatch
4344
from _pytest.nodes import Collector
@@ -170,6 +171,7 @@
170171
"importorskip",
171172
"main",
172173
"mark",
174+
"match_markexpr",
173175
"param",
174176
"raises",
175177
"register_assert_rewrite",

testing/test_match_markexpr.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# testing/test_match_markexpr.py
2+
from __future__ import annotations
3+
4+
from _pytest.config.exceptions import UsageError
5+
from _pytest.mark.expression import ParseError
6+
import pytest
7+
8+
9+
def test_public_api_present_match_markexpr() -> None:
10+
assert hasattr(pytest, "match_markexpr")
11+
assert callable(pytest.match_markexpr)
12+
13+
14+
def test_strings_markexpr() -> None:
15+
assert pytest.match_markexpr("smoke or slow", "slow")
16+
assert not pytest.match_markexpr("smoke and slow", "smoke")
17+
18+
19+
def test_iterables_markexpr() -> None:
20+
assert pytest.match_markexpr("smoke and not slow", ["smoke"])
21+
assert not pytest.match_markexpr("smoke and not slow", ["smoke", "slow"])
22+
assert pytest.match_markexpr("a and b", ["a", "b"])
23+
assert not pytest.match_markexpr("a and b", ["a"])
24+
25+
26+
def test_invalid_expression_raises() -> None:
27+
with pytest.raises(
28+
ParseError, match="expected not OR left parenthesis OR identifier; got and"
29+
):
30+
pytest.match_markexpr("smoke and and slow", ["smoke"])
31+
32+
33+
def test_type_error_for_unsupported_target() -> None:
34+
with pytest.raises(
35+
UsageError,
36+
match="target must be an Item-like object, a marker name string, or an iterable of marker names",
37+
):
38+
pytest.match_markexpr("smoke", 123)
39+
40+
41+
def test_item_matching_with_request_node(request: pytest.FixtureRequest) -> None:
42+
request.config.addinivalue_line("markers", "smoke: test marker")
43+
request.config.addinivalue_line("markers", "slow: test marker")
44+
45+
# Use the current test item; add/remove marks dynamically.
46+
request.node.add_marker("smoke")
47+
assert pytest.match_markexpr("smoke and not slow", request.node)
48+
assert not pytest.match_markexpr("slow", request.node)
49+
50+
# Add another mark and re-check
51+
request.node.add_marker("slow")
52+
assert not pytest.match_markexpr("smoke and not slow", request.node)
53+
assert pytest.match_markexpr("smoke and slow", request.node)

0 commit comments

Comments
 (0)