Skip to content

Commit 56688fb

Browse files
RonnyPfannschmidtCursor AIclaude
committed
fix: validate marker names in -m expression with --strict-markers
Closes #2781 Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Anthropic Claude Opus 4.5 <claude@anthropic.com>
1 parent 3d27ab9 commit 56688fb

5 files changed

Lines changed: 128 additions & 7 deletions

File tree

changelog/2781.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
When :confval:`strict_markers` is enabled, marker names used in :option:`-m` expressions are now validated against registered markers.

src/_pytest/mark/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
258258
return
259259

260260
expr = _parse_expression(matchexpr, "Wrong expression passed to '-m'")
261+
262+
# Validate marker names in the expression if strict_markers is enabled.
263+
_validate_marker_names(expr, config)
264+
261265
remaining: list[Item] = []
262266
deselected: list[Item] = []
263267
for item in items:
@@ -270,6 +274,34 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
270274
items[:] = remaining
271275

272276

277+
def _validate_marker_names(expr: Expression, config: Config) -> None:
278+
"""Validate that all marker names in the expression are registered.
279+
280+
Only validates when strict_markers is enabled.
281+
"""
282+
strict_markers = config.getini("strict_markers")
283+
if strict_markers is None:
284+
strict_markers = config.getini("strict")
285+
if not strict_markers:
286+
return
287+
288+
registered_markers: set[str] = set()
289+
for line in config.getini("markers"):
290+
# example lines: "skipif(condition): skip the given test if..."
291+
# or "hypothesis: tests which use Hypothesis", so to get the
292+
# marker name we split on both `:` and `(`.
293+
marker = line.split(":")[0].split("(")[0].strip()
294+
registered_markers.add(marker)
295+
296+
unknown_markers = expr.idents() - registered_markers
297+
if unknown_markers:
298+
unknown_str = ", ".join(sorted(unknown_markers))
299+
raise UsageError(
300+
f"Unknown marker(s) in '-m' expression: {unknown_str}. "
301+
"Use 'pytest --markers' to see available markers."
302+
)
303+
304+
273305
def _parse_expression(expr: str, exc_message: str) -> Expression:
274306
try:
275307
return Expression.compile(expr)

src/_pytest/mark/expression.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ class Token:
7070

7171

7272
class Scanner:
73-
__slots__ = ("current", "input", "tokens")
73+
__slots__ = ("current", "idents", "input", "tokens")
7474

7575
def __init__(self, input: str) -> None:
7676
self.input = input
77+
self.idents: set[str] = set()
7778
self.tokens = self.lex(input)
7879
self.current = next(self.tokens)
7980

@@ -163,13 +164,13 @@ def reject(self, expected: Sequence[TokenType]) -> NoReturn:
163164
IDENT_PREFIX = "$"
164165

165166

166-
def expression(s: Scanner) -> ast.Expression:
167+
def expression(s: Scanner) -> tuple[ast.Expression, frozenset[str]]:
167168
if s.accept(TokenType.EOF):
168169
ret: ast.expr = ast.Constant(False)
169170
else:
170171
ret = expr(s)
171172
s.accept(TokenType.EOF, reject=True)
172-
return ast.fix_missing_locations(ast.Expression(ret))
173+
return ast.fix_missing_locations(ast.Expression(ret)), frozenset(s.idents)
173174

174175

175176
def expr(s: Scanner) -> ast.expr:
@@ -197,6 +198,7 @@ def not_expr(s: Scanner) -> ast.expr:
197198
return ret
198199
ident = s.accept(TokenType.IDENT)
199200
if ident:
201+
s.idents.add(ident.value)
200202
name = ast.Name(IDENT_PREFIX + ident.value, ast.Load())
201203
if s.accept(TokenType.LPAREN):
202204
ret = ast.Call(func=name, args=[], keywords=all_kwargs(s))
@@ -314,12 +316,16 @@ class Expression:
314316
The expression can be evaluated against different matchers.
315317
"""
316318

317-
__slots__ = ("_code", "input")
319+
__slots__ = ("_code", "_idents", "input")
318320

319-
def __init__(self, input: str, code: types.CodeType) -> None:
321+
def __init__(
322+
self, input: str, code: types.CodeType, idents: frozenset[str]
323+
) -> None:
320324
#: The original input line, as a string.
321325
self.input: Final = input
322326
self._code: Final = code
327+
#: All identifiers which appear in the expression.
328+
self._idents: Final = idents
323329

324330
@classmethod
325331
def compile(cls, input: str) -> Expression:
@@ -329,13 +335,17 @@ def compile(cls, input: str) -> Expression:
329335
330336
:raises SyntaxError: If the expression is malformed.
331337
"""
332-
astexpr = expression(Scanner(input))
338+
astexpr, idents = expression(Scanner(input))
333339
code = compile(
334340
astexpr,
335341
filename="<pytest match expression>",
336342
mode="eval",
337343
)
338-
return Expression(input, code)
344+
return Expression(input, code, idents)
345+
346+
def idents(self) -> frozenset[str]:
347+
"""Return the set of all identifiers which appear in the expression."""
348+
return self._idents
339349

340350
def evaluate(self, matcher: ExpressionMatcher) -> bool:
341351
"""Evaluate the match expression.

testing/test_mark.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
import os
55
import sys
6+
from typing import cast
67
from unittest import mock
78

89
from _pytest.config import ExitCode
10+
from _pytest.config import UsageError
11+
from _pytest.mark import _validate_marker_names
912
from _pytest.mark import MarkGenerator
13+
from _pytest.mark.expression import Expression
1014
from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION
1115
from _pytest.nodes import Collector
1216
from _pytest.nodes import Node
@@ -213,6 +217,62 @@ def test_hello():
213217
)
214218

215219

220+
class TestValidateMarkerNames:
221+
"""Tests for _validate_marker_names (issue #2781)."""
222+
223+
class FakeConfig:
224+
def __init__(
225+
self,
226+
markers: list[str],
227+
strict_markers: bool | None = None,
228+
strict: bool = False,
229+
) -> None:
230+
self._ini: dict[str, list[str] | bool | None] = {
231+
"markers": markers,
232+
"strict_markers": strict_markers,
233+
"strict": strict,
234+
}
235+
236+
def getini(self, name: str) -> list[str] | bool | None:
237+
return self._ini[name]
238+
239+
def _make_config(
240+
self,
241+
strict_markers: bool | None = None,
242+
strict: bool = False,
243+
) -> pytest.Config:
244+
return cast(
245+
pytest.Config,
246+
self.FakeConfig(
247+
markers=["registered: a registered marker"],
248+
strict_markers=strict_markers,
249+
strict=strict,
250+
),
251+
)
252+
253+
def test_unknown_marker_with_strict_markers(self) -> None:
254+
expr = Expression.compile("unknown_marker")
255+
256+
with pytest.raises(UsageError, match=r"Unknown marker.*unknown_marker"):
257+
_validate_marker_names(expr, self._make_config(strict_markers=True))
258+
259+
def test_unknown_marker_with_strict(self) -> None:
260+
expr = Expression.compile("unknown_marker")
261+
262+
with pytest.raises(UsageError, match=r"Unknown marker.*unknown_marker"):
263+
_validate_marker_names(expr, self._make_config(strict=True))
264+
265+
def test_registered_marker_passes(self) -> None:
266+
expr = Expression.compile("registered")
267+
268+
_validate_marker_names(expr, self._make_config(strict_markers=True))
269+
270+
def test_no_validation_without_strict(self) -> None:
271+
expr = Expression.compile("any_marker")
272+
273+
_validate_marker_names(expr, self._make_config())
274+
275+
216276
@pytest.mark.parametrize(
217277
("expr", "expected_passed"),
218278
[

testing/test_mark_expression.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,21 @@ def test_str_keyword_expressions(
322322
expr: str, expected: bool, mark_matcher: MarkMatcher
323323
) -> None:
324324
assert evaluate(expr, mark_matcher) is expected
325+
326+
327+
@pytest.mark.parametrize(
328+
("expr", "expected_idents"),
329+
(
330+
("", frozenset()),
331+
("foo", frozenset(["foo"])),
332+
("foo and bar", frozenset(["foo", "bar"])),
333+
("foo or bar", frozenset(["foo", "bar"])),
334+
("not foo", frozenset(["foo"])),
335+
("(foo and bar) or baz", frozenset(["foo", "bar", "baz"])),
336+
("foo and foo", frozenset(["foo"])), # Duplicates are deduplicated.
337+
("mark(a=1)", frozenset(["mark"])), # Only marker name, not kwargs.
338+
),
339+
)
340+
def test_expression_idents(expr: str, expected_idents: frozenset[str]) -> None:
341+
"""Test that Expression.idents() returns the identifiers in the expression."""
342+
assert Expression.compile(expr).idents() == expected_idents

0 commit comments

Comments
 (0)