Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/12582.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest now warns when the reason given to ``pytest.mark.skip`` looks like a condition (for example ``"sys.platform == 'win32'"``), since ``pytest.mark.skipif`` was probably intended.
44 changes: 44 additions & 0 deletions src/_pytest/skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import ast
from collections.abc import Generator
from collections.abc import Mapping
import dataclasses
Expand All @@ -24,6 +25,7 @@
from _pytest.reports import TestReport
from _pytest.runner import CallInfo
from _pytest.stash import StashKey
from _pytest.warning_types import PytestWarning


def pytest_addoption(parser: Parser) -> None:
Expand Down Expand Up @@ -166,6 +168,32 @@ class Skip:
reason: str = "unconditional skip"


warned_skip_reasons_key = StashKey[set[str]]()


def _looks_like_condition(reason: object) -> bool:
"""Return whether a skip reason looks like a skipif condition, e.g.
``"sys.platform == 'win32'"`` (#12582)."""
if not isinstance(reason, str):
return False
try:
tree = ast.parse(reason.strip(), mode="eval")
except (SyntaxError, ValueError):
return False
for node in ast.walk(tree):
# Attribute access or calls, for example ``sys.platform.startswith('win')``.
if isinstance(node, ast.Attribute | ast.Call):
return True
# Comparisons against values, for example ``os.name == 'nt'``; comparisons
# of plain names are ignored as they are also valid English, for example
# ``"nothing in particular"``.
if isinstance(node, ast.Compare) and not all(
isinstance(operand, ast.Name) for operand in [node.left, *node.comparators]
):
return True
return False
Comment on lines +183 to +194

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I favor a simpler approach: rather than checking the contents of the expression, just reject those which contain only letters/space/dot:

Suggested change
for node in ast.walk(tree):
# Attribute access or calls, for example ``sys.platform.startswith('win')``.
if isinstance(node, ast.Attribute | ast.Call):
return True
# Comparisons against values, for example ``os.name == 'nt'``; comparisons
# of plain names are ignored as they are also valid English, for example
# ``"nothing in particular"``.
if isinstance(node, ast.Compare) and not all(
isinstance(operand, ast.Name) for operand in [node.left, *node.comparators]
):
return True
return False
return re.fullmatch(r"[A-z .]+", reason) is None

it seems to me that this better captures the core "looks like valid english" issue, and is very simple.

@LuanaPBueno LuanaPBueno Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, Zac! I like the idea of making it simpler. I have two small concerns about the regex, though:

'[A-z]' also matches a few non-letter characters ('[', '', ']', '^', '_'), since they come between 'Z' and 'a' in ASCII. I think '[A-Za-z .]' is what we want here.
Checking only for letters, spaces and dots would also warn on many common reasons that are just normal sentences, for example:

I worry this could add a lot of noise to existing test suites, which is the same problem I ran into with the original rule from the issue.

One middle ground could be to keep the regex approach but allow more characters that are common in normal English, like '[\w\s.,'#:!?-]+'. That way digits, commas and apostrophes are fine, while things like '==', '<' or parentheses still warn. But I'm happy to go with whichever you prefer, either that or keeping the 'ast' version. What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking only for letters, spaces and dots would also warn on many common reasons that are just normal sentences, for example:

These examples all raise an error in ast.parse(...) - my proposal is that we should warn on anything which parses as Python, unless it consists only of letters, space, and dot.

@LuanaPBueno LuanaPBueno Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried your approach locally, and it works well, except for one case: test_warnings.py has @pytest.mark.skip("issue #13485"), which parses as Python (# starts a comment), so it warns and that test fails. Should I ignore everything after #, or change that skip reason? I’ll push once we decide.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd change it, eg to "see issue #...."; one case seems like acceptable collateral for a simple rule.



def evaluate_skip_marks(item: Item) -> Skip | None:
"""Evaluate skip and skipif marks on item, returning Skip if triggered."""
for mark in item.iter_markers(name="skipif"):
Expand All @@ -186,6 +214,22 @@ def evaluate_skip_marks(item: Item) -> Skip | None:
return Skip(reason)

for mark in item.iter_markers(name="skip"):
reason = mark.args[0] if mark.args else mark.kwargs.get("reason")
# Marks applied to a module or class are seen by each of their items,
# so only warn once per reason.
warned_reasons = item.config.stash.setdefault(warned_skip_reasons_key, set())
if (
isinstance(reason, str)
and _looks_like_condition(reason)
and reason not in warned_reasons
):
warned_reasons.add(reason)
item.warn(
PytestWarning(
f"The reason {reason!r} of pytest.mark.skip looks like a condition. "
"Did you mean pytest.mark.skipif?"
)
)
try:
return Skip(*mark.args, **mark.kwargs)
except TypeError as e:
Expand Down
73 changes: 73 additions & 0 deletions testing/test_skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -1567,3 +1567,76 @@ def test_exit_reason_only():
)
result = pytester.runpytest(p)
result.stdout.fnmatch_lines("*_pytest.outcomes.Exit: foo*")


@pytest.mark.parametrize(
"reason",
[
"sys.version_info < (3, 6)",
"sys.platform.startswith('win')",
"os.name == 'nt'",
],
)
def test_skip_reason_looks_like_condition_warns(
pytester: Pytester, reason: str
) -> None:
pytester.makepyfile(
f"""
import pytest

@pytest.mark.skip({reason!r})
def test_func():
pass
"""
)
result = pytester.runpytest("-W", "default::pytest.PytestWarning")
result.stdout.fnmatch_lines(
["*looks like a condition*Did you mean pytest.mark.skipif?"]
)
result.assert_outcomes(skipped=1, warnings=1)


@pytest.mark.parametrize(
"reason",
[
"flaky",
"not supported on windows",
"nothing in particular",
"Test is not isolated",
],
)
def test_skip_reason_plain_text_does_not_warn(pytester: Pytester, reason: str) -> None:
pytester.makepyfile(
f"""
import pytest

@pytest.mark.skip({reason!r})
def test_func():
pass
"""
)
result = pytester.runpytest("-W", "default::pytest.PytestWarning")
result.assert_outcomes(skipped=1, warnings=0)


def test_skip_reason_looks_like_condition_warns_once_per_reason(
pytester: Pytester,
) -> None:
"""A module- or class-level mark applies to every item, but warns only once."""
pytester.makepyfile(
"""
import pytest

pytestmark = pytest.mark.skip("sys.platform == 'win32'")

def test_a(): pass
def test_b(): pass

@pytest.mark.skip("os.name == 'nt'")
class TestClass:
def test_c(self): pass
def test_d(self): pass
"""
)
result = pytester.runpytest("-W", "default::pytest.PytestWarning")
result.assert_outcomes(skipped=4, warnings=2)
Loading