Skip to content

Commit 5fd2be0

Browse files
Merge pull request #14127 from RonnyPfannschmidt/fix-2781-strict-markers-m-expression
fix: validate marker names in -m expression with --strict-markers
2 parents 0d6fbde + 3c47497 commit 5fd2be0

8 files changed

Lines changed: 208 additions & 19 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.

doc/en/reference/reference.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2479,6 +2479,9 @@ passed multiple times. The expected format is ``name=value``. For example::
24792479

24802480
If set to ``true``, markers not registered in the ``markers`` section of the configuration file will raise errors.
24812481

2482+
This applies both to markers applied to tests (e.g. ``@pytest.mark.slow``) and to marker
2483+
names used in :option:`-m` expressions.
2484+
24822485
.. tab:: toml
24832486

24842487
.. code-block:: toml

src/_pytest/config/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from typing import final
3737
from typing import IO
3838
from typing import Literal
39+
from typing import NamedTuple
3940
from typing import TextIO
4041
from typing import TYPE_CHECKING
4142
import warnings
@@ -1044,6 +1045,17 @@ def __len__(self) -> int:
10441045
return len(self._config._inicfg)
10451046

10461047

1048+
class RegisteredMarker(NamedTuple):
1049+
"""A marker registered in the configuration."""
1050+
1051+
#: The marker name (e.g., ``skipif``).
1052+
name: str
1053+
#: The full marker signature (e.g., ``skipif(condition)``).
1054+
signature: str
1055+
#: The marker description.
1056+
description: str
1057+
1058+
10471059
@final
10481060
class Config:
10491061
"""Access to configuration values, pluginmanager and plugin hooks.
@@ -1788,6 +1800,18 @@ def getini(self, name: str) -> Any:
17881800
self._inicache[canonical_name] = val = self._getini(canonical_name)
17891801
return val
17901802

1803+
def _iter_registered_markers(self) -> Iterator[RegisteredMarker]:
1804+
"""Iterate over all markers registered in the configuration."""
1805+
for line in self.getini("markers"):
1806+
# Example lines: "skipif(condition): skip the given test if..."
1807+
# or "hypothesis: tests which use Hypothesis", so to get the
1808+
# marker name we split on both `:` and `(`.
1809+
parts = line.split(":", 1)
1810+
signature = parts[0]
1811+
description = parts[1].strip() if len(parts) == 2 else ""
1812+
name = signature.split("(")[0].strip()
1813+
yield RegisteredMarker(name, signature, description)
1814+
17911815
# Meant for easy monkeypatching by legacypath plugin.
17921816
# Can be inlined back (with no cover removed) once legacypath is gone.
17931817
def _getini_unknown_type(self, name: str, type: str, value: object):

src/_pytest/mark/__init__.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,9 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
140140
if config.option.markers:
141141
config._do_configure()
142142
tw = _pytest.config.create_terminal_writer(config)
143-
for line in config.getini("markers"):
144-
parts = line.split(":", 1)
145-
name = parts[0]
146-
rest = parts[1] if len(parts) == 2 else ""
147-
tw.write(f"@pytest.mark.{name}:", bold=True)
148-
tw.line(rest)
143+
for marker in config._iter_registered_markers():
144+
tw.write(f"@pytest.mark.{marker.signature}:", bold=True)
145+
tw.line(f" {marker.description}" if marker.description else "")
149146
tw.line()
150147
config._ensure_unconfigure()
151148
return 0
@@ -264,6 +261,8 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
264261
return
265262

266263
expr = _parse_expression(matchexpr, "Wrong expression passed to '-m'")
264+
_validate_marker_names(expr, config)
265+
267266
remaining: list[Item] = []
268267
deselected: list[Item] = []
269268
for item in items:
@@ -276,6 +275,28 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
276275
items[:] = remaining
277276

278277

278+
def _validate_marker_names(expr: Expression, config: Config) -> None:
279+
"""Validate that all marker names in the expression are registered.
280+
281+
Only validates when strict_markers is enabled.
282+
"""
283+
strict_markers = config.getini("strict_markers")
284+
if strict_markers is None:
285+
strict_markers = config.getini("strict")
286+
if not strict_markers:
287+
return
288+
289+
registered_markers = {m.name for m in config._iter_registered_markers()}
290+
291+
unknown_markers = expr.idents() - registered_markers
292+
if unknown_markers:
293+
unknown_str = ", ".join(sorted(unknown_markers))
294+
raise UsageError(
295+
f"Unknown marker(s) in '-m' expression: {unknown_str}. "
296+
"Use 'pytest --markers' to see available markers."
297+
)
298+
299+
279300
def _parse_expression(expr: str, exc_message: str) -> Expression:
280301
try:
281302
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.

src/_pytest/mark/structures.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -604,12 +604,9 @@ def __getattr__(self, name: str) -> MarkDecorator:
604604
# name is in the set we definitely know it, but a mark may be known and
605605
# not in the set. We therefore start by updating the set!
606606
if name not in self._markers:
607-
for line in self._config.getini("markers"):
608-
# example lines: "skipif(condition): skip the given test if..."
609-
# or "hypothesis: tests which use Hypothesis", so to get the
610-
# marker name we split on both `:` and `(`.
611-
marker = line.split(":")[0].split("(")[0].strip()
612-
self._markers.add(marker)
607+
self._markers.update(
608+
m.name for m in self._config._iter_registered_markers()
609+
)
613610

614611
# If the name is not in the set of known marks after updating,
615612
# then it really is time to issue a warning or an error.

testing/test_mark.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
# mypy: allow-untyped-defs
22
from __future__ import annotations
33

4+
from collections.abc import Iterator
45
import os
56
import sys
7+
from typing import cast
68
from unittest import mock
79

10+
from _pytest.config import Config
811
from _pytest.config import ExitCode
12+
from _pytest.config import RegisteredMarker
13+
from _pytest.config import UsageError
14+
from _pytest.mark import _validate_marker_names
915
from _pytest.mark import MarkGenerator
16+
from _pytest.mark.expression import Expression
1017
from _pytest.mark.structures import _EmptyParameterSetMark
1118
from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION
1219
from _pytest.nodes import Collector
@@ -219,6 +226,114 @@ def test_hello():
219226
)
220227

221228

229+
class TestValidateMarkerNames:
230+
"""Tests for _validate_marker_names (issue #2781)."""
231+
232+
class FakeConfig:
233+
def __init__(
234+
self,
235+
markers: list[str],
236+
strict_markers: bool | None = None,
237+
strict: bool = False,
238+
) -> None:
239+
self._ini: dict[str, list[str] | bool | None] = {
240+
"markers": markers,
241+
"strict_markers": strict_markers,
242+
"strict": strict,
243+
}
244+
245+
def getini(self, name: str) -> list[str] | bool | None:
246+
return self._ini[name]
247+
248+
def _iter_registered_markers(self) -> Iterator[RegisteredMarker]:
249+
yield from Config._iter_registered_markers(cast(Config, self))
250+
251+
def _make_config(
252+
self,
253+
strict_markers: bool | None = None,
254+
strict: bool = False,
255+
) -> Config:
256+
return cast(
257+
Config,
258+
self.FakeConfig(
259+
markers=["registered: a registered marker"],
260+
strict_markers=strict_markers,
261+
strict=strict,
262+
),
263+
)
264+
265+
def test_unknown_marker_with_strict_markers(self) -> None:
266+
expr = Expression.compile("unknown_marker")
267+
268+
with pytest.raises(UsageError, match=r"Unknown marker.*unknown_marker"):
269+
_validate_marker_names(expr, self._make_config(strict_markers=True))
270+
271+
def test_unknown_marker_with_strict(self) -> None:
272+
expr = Expression.compile("unknown_marker")
273+
274+
with pytest.raises(UsageError, match=r"Unknown marker.*unknown_marker"):
275+
_validate_marker_names(expr, self._make_config(strict=True))
276+
277+
def test_registered_marker_passes(self) -> None:
278+
expr = Expression.compile("registered")
279+
280+
_validate_marker_names(expr, self._make_config(strict_markers=True))
281+
282+
def test_no_validation_without_strict(self) -> None:
283+
expr = Expression.compile("any_marker")
284+
285+
_validate_marker_names(expr, self._make_config())
286+
287+
288+
@pytest.fixture
289+
def markexpr_pytester(pytester: Pytester) -> Pytester:
290+
pytester.makeini(
291+
"""
292+
[pytest]
293+
markers =
294+
registered: a registered marker
295+
"""
296+
)
297+
pytester.makepyfile(
298+
"""
299+
import pytest
300+
301+
@pytest.mark.registered
302+
def test_registered():
303+
pass
304+
305+
def test_plain():
306+
pass
307+
"""
308+
)
309+
return pytester
310+
311+
312+
@pytest.mark.parametrize("option", ["--strict-markers", "--strict"])
313+
def test_strict_prohibits_unregistered_markers_in_markexpr(
314+
markexpr_pytester: Pytester, option: str
315+
) -> None:
316+
result = markexpr_pytester.runpytest(option, "-m", "registered or unregisteredmark")
317+
assert result.ret == ExitCode.USAGE_ERROR
318+
result.stderr.fnmatch_lines(
319+
["*Unknown marker(s) in '-m' expression: unregisteredmark*"]
320+
)
321+
322+
323+
def test_strict_allows_registered_markers_in_markexpr(
324+
markexpr_pytester: Pytester,
325+
) -> None:
326+
result = markexpr_pytester.runpytest("--strict-markers", "-m", "registered")
327+
result.assert_outcomes(passed=1, deselected=1)
328+
329+
330+
def test_unregistered_markers_in_markexpr_allowed_without_strict(
331+
markexpr_pytester: Pytester,
332+
) -> None:
333+
result = markexpr_pytester.runpytest("-m", "unregisteredmark")
334+
result.assert_outcomes(deselected=2)
335+
336+
222337
@pytest.mark.parametrize(
223338
("expr", "expected_passed"),
224339
[

testing/test_mark_expression.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,21 @@ def test_str_keyword_expressions(
336336
expr: str, expected: bool, mark_matcher: MarkMatcher
337337
) -> None:
338338
assert evaluate(expr, mark_matcher) is expected
339+
340+
341+
@pytest.mark.parametrize(
342+
("expr", "expected_idents"),
343+
(
344+
("", frozenset()),
345+
("foo", frozenset(["foo"])),
346+
("foo and bar", frozenset(["foo", "bar"])),
347+
("foo or bar", frozenset(["foo", "bar"])),
348+
("not foo", frozenset(["foo"])),
349+
("(foo and bar) or baz", frozenset(["foo", "bar", "baz"])),
350+
("foo and foo", frozenset(["foo"])), # Duplicates are deduplicated.
351+
("mark(a=1)", frozenset(["mark"])), # Only marker name, not kwargs.
352+
),
353+
)
354+
def test_expression_idents(expr: str, expected_idents: frozenset[str]) -> None:
355+
"""Test that Expression.idents() returns the identifiers in the expression."""
356+
assert Expression.compile(expr).idents() == expected_idents

0 commit comments

Comments
 (0)