diff --git a/changelog/13759.feature.rst b/changelog/13759.feature.rst new file mode 100644 index 00000000000..bb42a5824cd --- /dev/null +++ b/changelog/13759.feature.rst @@ -0,0 +1,3 @@ +Added public API function `pytest.match_markexpr` to match marker expressions against a set of markers or a pytest Item. +This function is useful for plugins and test code that need to evaluate marker expressions without relying on internal APIs. +Insuring that the public API is properly tested and documented. diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index babcd9e2f3a..8a735cf7232 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -757,3 +757,28 @@ or to select both "event" and "interface" tests: FAILED test_module.py::test_interface_complex - assert 0 FAILED test_module.py::test_event_simple - assert 0 ===================== 3 failed, 1 deselected in 0.12s ====================== + +.. _match_markexpr: + +Checking expression matches against markers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Evaluate a marker expression against a pytest Item or a set of marker names. +.. versionadded:: 8.4 + +.. code-block:: python + + import pytest + + + def test_example(): + item = ... # some pytest Item + assert pytest.match_markexpr("smoke and not slow", item) + + + def test_example2(): + marks = ["smoke", "fast"] + assert pytest.match_markexpr("smoke and not slow", marks) + +This function is useful for plugins and test code that need to evaluate marker expressions +without relying on internal APIs. diff --git a/doc/en/how-to/mark.rst b/doc/en/how-to/mark.rst index 33f9d18bfe3..83b1ab68012 100644 --- a/doc/en/how-to/mark.rst +++ b/doc/en/how-to/mark.rst @@ -91,3 +91,31 @@ enforce this validation in your project by adding ``--strict-markers`` to ``addo markers = slow: marks tests as slow (deselect with '-m "not slow"') serial + + +Check if marks match an expression without test collection +---------------------------------------------------------- + +You can check if a set of marks or a pytest Item matches a marker expression +using the public API function :func:`pytest.match_markexpr`. + +.. code-block:: python + + import pytest + + + def test_example(): + item = ... # some pytest Item + assert pytest.match_markexpr("smoke and not slow", item) + + + def test_example2(): + marks = ["smoke", "fast"] + assert pytest.match_markexpr("smoke and not slow", marks) + + + def test_example3(requests): + assert not pytest.match_markexpr("smoke and not slow", requests.node) + +This function is useful for plugins and test code that need to evaluate marker expressions +without relying on internal APIs. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 443530a2006..d1d28d949de 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -148,12 +148,19 @@ pytest.warns :with: pytest.freeze_includes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ **Tutorial**: :ref:`freezing-pytest` .. autofunction:: pytest.freeze_includes +pytest.match_markexpr +~~~~~~~~~~~~~~~~~~~~~ + +**Tutorial**: :ref:`match_markexpr` + +.. autofunction:: pytest.match_markexpr + .. _`marks ref`: Marks @@ -289,6 +296,7 @@ Marks a test function as *expected to fail*. Defaults to :confval:`xfail_strict`, which is ``False`` by default. + Custom marks ~~~~~~~~~~~~ diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index cd59069559e..7edb4914c8f 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -41,6 +41,7 @@ "MarkGenerator", "ParameterSet", "get_empty_parameterset_mark", + "match_markexpr", ] @@ -298,3 +299,42 @@ def pytest_configure(config: Config) -> None: def pytest_unconfigure(config: Config) -> None: MARK_GEN._config = config.stash.get(old_mark_config_key, None) + + +def match_markexpr(expr: str, mark: str | Iterable[str] | object) -> bool: + """ + Match a marker expression against a marker name, an iterable of marker names, + or an Item-like object. + + :param expr: + The marker expression to evaluate. + :param mark: + A marker name string, an iterable of marker names, or an Item-like object. + :return: + True if the expression matches, False otherwise. + :raises UsageError: + If the target type is unsupported. + + """ + try: + compiled = Expression.compile(expr) + except ParseError as e: + raise e + + # Build an iterable of Mark objects for the matcher + if isinstance(mark, object) and hasattr(mark, "iter_markers"): + markers_iter = mark.iter_markers() + elif isinstance(mark, str): + markers_iter = (Mark(mark, (), {}, _ispytest=True),) + elif isinstance(mark, Iterable) and not isinstance(mark, (str, bytes, bytearray)): + markers_iter = (Mark(name, (), {}, _ispytest=True) for name in mark) + + else: + raise UsageError( + "target must be an Item-like object, a marker name string, " + "or an iterable of marker names" + ) + + # MarkMatcher is defined in this module (_pytest.mark) + matcher = MarkMatcher.from_markers(markers_iter) + return compiled.evaluate(matcher) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 31d56deede4..cdbf2cf5c97 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -38,6 +38,7 @@ from _pytest.mark import MARK_GEN as mark from _pytest.mark import MarkDecorator from _pytest.mark import MarkGenerator +from _pytest.mark import match_markexpr from _pytest.mark import param from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector @@ -170,6 +171,7 @@ "importorskip", "main", "mark", + "match_markexpr", "param", "raises", "register_assert_rewrite", diff --git a/testing/test_match_markexpr.py b/testing/test_match_markexpr.py new file mode 100644 index 00000000000..5f7c1a2100f --- /dev/null +++ b/testing/test_match_markexpr.py @@ -0,0 +1,53 @@ +# testing/test_match_markexpr.py +from __future__ import annotations + +from _pytest.config.exceptions import UsageError +from _pytest.mark.expression import ParseError +import pytest + + +def test_public_api_present_match_markexpr() -> None: + assert hasattr(pytest, "match_markexpr") + assert callable(pytest.match_markexpr) + + +def test_strings_markexpr() -> None: + assert pytest.match_markexpr("smoke or slow", "slow") + assert not pytest.match_markexpr("smoke and slow", "smoke") + + +def test_iterables_markexpr() -> None: + assert pytest.match_markexpr("smoke and not slow", ["smoke"]) + assert not pytest.match_markexpr("smoke and not slow", ["smoke", "slow"]) + assert pytest.match_markexpr("a and b", ["a", "b"]) + assert not pytest.match_markexpr("a and b", ["a"]) + + +def test_invalid_expression_raises() -> None: + with pytest.raises( + ParseError, match="expected not OR left parenthesis OR identifier; got and" + ): + pytest.match_markexpr("smoke and and slow", ["smoke"]) + + +def test_type_error_for_unsupported_target() -> None: + with pytest.raises( + UsageError, + match="target must be an Item-like object, a marker name string, or an iterable of marker names", + ): + pytest.match_markexpr("smoke", 123) + + +def test_item_matching_with_request_node(request: pytest.FixtureRequest) -> None: + request.config.addinivalue_line("markers", "smoke: test marker") + request.config.addinivalue_line("markers", "slow: test marker") + + # Use the current test item; add/remove marks dynamically. + request.node.add_marker("smoke") + assert pytest.match_markexpr("smoke and not slow", request.node) + assert not pytest.match_markexpr("slow", request.node) + + # Add another mark and re-check + request.node.add_marker("slow") + assert not pytest.match_markexpr("smoke and not slow", request.node) + assert pytest.match_markexpr("smoke and slow", request.node)