Skip to content

Commit 9f134fc

Browse files
Merge pull request #12500 from lovetheguitar/feat/support_marker_kwarg_in_marker_expressions
2 parents e8fa8dd + 75a2225 commit 9f134fc

File tree

9 files changed

+356
-20
lines changed

9 files changed

+356
-20
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Levon Saldamli
245245
Lewis Cowles
246246
Llandy Riveron Del Risco
247247
Loic Esteve
248+
lovetheguitar
248249
Lukas Bednar
249250
Luke Murphy
250251
Maciek Fijalkowski

changelog/12281.feature.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Added support for keyword matching in marker expressions.
2+
3+
Now tests can be selected by marker keyword arguments.
4+
Supported values are :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None`.
5+
6+
See :ref:`marker examples <marker_keyword_expression_example>` for more information.
7+
8+
-- by :user:`lovetheguitar`

doc/en/example/markers.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ You can "mark" a test function with custom metadata like this:
2525
pass # perform some webtest test for your app
2626
2727
28+
@pytest.mark.device(serial="123")
2829
def test_something_quick():
2930
pass
3031
3132
33+
@pytest.mark.device(serial="abc")
3234
def test_another():
3335
pass
3436
@@ -71,6 +73,28 @@ Or the inverse, running all tests except the webtest ones:
7173
7274
===================== 3 passed, 1 deselected in 0.12s ======================
7375
76+
.. _`marker_keyword_expression_example`:
77+
78+
Additionally, you can restrict a test run to only run tests matching one or multiple marker
79+
keyword arguments, e.g. to run only tests marked with ``device`` and the specific ``serial="123"``:
80+
81+
.. code-block:: pytest
82+
83+
$ pytest -v -m 'device(serial="123")'
84+
=========================== test session starts ============================
85+
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
86+
cachedir: .pytest_cache
87+
rootdir: /home/sweet/project
88+
collecting ... collected 4 items / 3 deselected / 1 selected
89+
90+
test_server.py::test_something_quick PASSED [100%]
91+
92+
===================== 1 passed, 3 deselected in 0.12s ======================
93+
94+
.. note:: Only keyword argument matching is supported in marker expressions.
95+
96+
.. note:: Only :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None` values are supported in marker expressions.
97+
7498
Selecting tests based on their node ID
7599
--------------------------------------
76100

doc/en/how-to/usage.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,19 @@ Specifying a specific parametrization of a test:
7676
7777
**Run tests by marker expressions**
7878

79+
To run all tests which are decorated with the ``@pytest.mark.slow`` decorator:
80+
7981
.. code-block:: bash
8082
8183
pytest -m slow
8284
83-
Will run all tests which are decorated with the ``@pytest.mark.slow`` decorator.
85+
86+
To run all tests which are decorated with the annotated ``@pytest.mark.slow(phase=1)`` decorator,
87+
with the ``phase`` keyword argument set to ``1``:
88+
89+
.. code-block:: bash
90+
91+
pytest -m slow(phase=1)
8492
8593
For more information see :ref:`marks <mark>`.
8694

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ markers = [
358358
"foo",
359359
"bar",
360360
"baz",
361+
"number_mark",
362+
"builtin_matchers_mark",
363+
"str_mark",
361364
# conftest.py reorders tests moving slow ones to the end of the list
362365
"slow",
363366
# experimental mark for all tests using pexpect

src/_pytest/mark/__init__.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5+
import collections
56
import dataclasses
67
from typing import AbstractSet
78
from typing import Collection
9+
from typing import Iterable
810
from typing import Optional
911
from typing import TYPE_CHECKING
1012

@@ -21,6 +23,7 @@
2123
from _pytest.config import ExitCode
2224
from _pytest.config import hookimpl
2325
from _pytest.config import UsageError
26+
from _pytest.config.argparsing import NOT_SET
2427
from _pytest.config.argparsing import Parser
2528
from _pytest.stash import StashKey
2629

@@ -181,7 +184,9 @@ def from_item(cls, item: Item) -> KeywordMatcher:
181184

182185
return cls(mapped_names)
183186

184-
def __call__(self, subname: str) -> bool:
187+
def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool:
188+
if kwargs:
189+
raise UsageError("Keyword expressions do not support call parameters.")
185190
subname = subname.lower()
186191
names = (name.lower() for name in self._names)
187192

@@ -218,17 +223,26 @@ class MarkMatcher:
218223
Tries to match on any marker names, attached to the given colitem.
219224
"""
220225

221-
__slots__ = ("own_mark_names",)
226+
__slots__ = ("own_mark_name_mapping",)
222227

223-
own_mark_names: AbstractSet[str]
228+
own_mark_name_mapping: dict[str, list[Mark]]
224229

225230
@classmethod
226-
def from_item(cls, item: Item) -> MarkMatcher:
227-
mark_names = {mark.name for mark in item.iter_markers()}
228-
return cls(mark_names)
231+
def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher:
232+
mark_name_mapping = collections.defaultdict(list)
233+
for mark in markers:
234+
mark_name_mapping[mark.name].append(mark)
235+
return cls(mark_name_mapping)
236+
237+
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool:
238+
if not (matches := self.own_mark_name_mapping.get(name, [])):
239+
return False
240+
241+
for mark in matches:
242+
if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()):
243+
return True
229244

230-
def __call__(self, name: str) -> bool:
231-
return name in self.own_mark_names
245+
return False
232246

233247

234248
def deselect_by_mark(items: list[Item], config: Config) -> None:
@@ -240,7 +254,7 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
240254
remaining: list[Item] = []
241255
deselected: list[Item] = []
242256
for item in items:
243-
if expr.evaluate(MarkMatcher.from_item(item)):
257+
if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())):
244258
remaining.append(item)
245259
else:
246260
deselected.append(item)

src/_pytest/mark/expression.py

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
expression: expr? EOF
66
expr: and_expr ('or' and_expr)*
77
and_expr: not_expr ('and' not_expr)*
8-
not_expr: 'not' not_expr | '(' expr ')' | ident
8+
not_expr: 'not' not_expr | '(' expr ')' | ident ( '(' name '=' value ( ', ' name '=' value )* ')')*
9+
910
ident: (\w|:|\+|-|\.|\[|\]|\\|/)+
1011
1112
The semantics are:
@@ -20,12 +21,15 @@
2021
import ast
2122
import dataclasses
2223
import enum
24+
import keyword
2325
import re
2426
import types
25-
from typing import Callable
2627
from typing import Iterator
28+
from typing import Literal
2729
from typing import Mapping
2830
from typing import NoReturn
31+
from typing import overload
32+
from typing import Protocol
2933
from typing import Sequence
3034

3135

@@ -43,6 +47,9 @@ class TokenType(enum.Enum):
4347
NOT = "not"
4448
IDENT = "identifier"
4549
EOF = "end of input"
50+
EQUAL = "="
51+
STRING = "str"
52+
COMMA = ","
4653

4754

4855
@dataclasses.dataclass(frozen=True)
@@ -86,6 +93,27 @@ def lex(self, input: str) -> Iterator[Token]:
8693
elif input[pos] == ")":
8794
yield Token(TokenType.RPAREN, ")", pos)
8895
pos += 1
96+
elif input[pos] == "=":
97+
yield Token(TokenType.EQUAL, "=", pos)
98+
pos += 1
99+
elif input[pos] == ",":
100+
yield Token(TokenType.COMMA, ",", pos)
101+
pos += 1
102+
elif (quote_char := input[pos]) in ("'", '"'):
103+
end_quote_pos = input.find(quote_char, pos + 1)
104+
if end_quote_pos == -1:
105+
raise ParseError(
106+
pos + 1,
107+
f'closing quote "{quote_char}" is missing',
108+
)
109+
value = input[pos : end_quote_pos + 1]
110+
if (backslash_pos := input.find("\\")) != -1:
111+
raise ParseError(
112+
backslash_pos + 1,
113+
r'escaping with "\" not supported in marker expression',
114+
)
115+
yield Token(TokenType.STRING, value, pos)
116+
pos += len(value)
89117
else:
90118
match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:])
91119
if match:
@@ -106,6 +134,14 @@ def lex(self, input: str) -> Iterator[Token]:
106134
)
107135
yield Token(TokenType.EOF, "", pos)
108136

137+
@overload
138+
def accept(self, type: TokenType, *, reject: Literal[True]) -> Token: ...
139+
140+
@overload
141+
def accept(
142+
self, type: TokenType, *, reject: Literal[False] = False
143+
) -> Token | None: ...
144+
109145
def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
110146
if self.current.type is type:
111147
token = self.current
@@ -166,18 +202,87 @@ def not_expr(s: Scanner) -> ast.expr:
166202
return ret
167203
ident = s.accept(TokenType.IDENT)
168204
if ident:
169-
return ast.Name(IDENT_PREFIX + ident.value, ast.Load())
205+
name = ast.Name(IDENT_PREFIX + ident.value, ast.Load())
206+
if s.accept(TokenType.LPAREN):
207+
ret = ast.Call(func=name, args=[], keywords=all_kwargs(s))
208+
s.accept(TokenType.RPAREN, reject=True)
209+
else:
210+
ret = name
211+
return ret
212+
170213
s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))
171214

172215

173-
class MatcherAdapter(Mapping[str, bool]):
216+
BUILTIN_MATCHERS = {"True": True, "False": False, "None": None}
217+
218+
219+
def single_kwarg(s: Scanner) -> ast.keyword:
220+
keyword_name = s.accept(TokenType.IDENT, reject=True)
221+
if not keyword_name.value.isidentifier():
222+
raise ParseError(
223+
keyword_name.pos + 1,
224+
f"not a valid python identifier {keyword_name.value}",
225+
)
226+
if keyword.iskeyword(keyword_name.value):
227+
raise ParseError(
228+
keyword_name.pos + 1,
229+
f"unexpected reserved python keyword `{keyword_name.value}`",
230+
)
231+
s.accept(TokenType.EQUAL, reject=True)
232+
233+
if value_token := s.accept(TokenType.STRING):
234+
value: str | int | bool | None = value_token.value[1:-1] # strip quotes
235+
else:
236+
value_token = s.accept(TokenType.IDENT, reject=True)
237+
if (
238+
(number := value_token.value).isdigit()
239+
or number.startswith("-")
240+
and number[1:].isdigit()
241+
):
242+
value = int(number)
243+
elif value_token.value in BUILTIN_MATCHERS:
244+
value = BUILTIN_MATCHERS[value_token.value]
245+
else:
246+
raise ParseError(
247+
value_token.pos + 1,
248+
f'unexpected character/s "{value_token.value}"',
249+
)
250+
251+
ret = ast.keyword(keyword_name.value, ast.Constant(value))
252+
return ret
253+
254+
255+
def all_kwargs(s: Scanner) -> list[ast.keyword]:
256+
ret = [single_kwarg(s)]
257+
while s.accept(TokenType.COMMA):
258+
ret.append(single_kwarg(s))
259+
return ret
260+
261+
262+
class MatcherCall(Protocol):
263+
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ...
264+
265+
266+
@dataclasses.dataclass
267+
class MatcherNameAdapter:
268+
matcher: MatcherCall
269+
name: str
270+
271+
def __bool__(self) -> bool:
272+
return self.matcher(self.name)
273+
274+
def __call__(self, **kwargs: str | int | bool | None) -> bool:
275+
return self.matcher(self.name, **kwargs)
276+
277+
278+
class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
174279
"""Adapts a matcher function to a locals mapping as required by eval()."""
175280

176-
def __init__(self, matcher: Callable[[str], bool]) -> None:
281+
def __init__(self, matcher: MatcherCall) -> None:
177282
self.matcher = matcher
178283

179-
def __getitem__(self, key: str) -> bool:
180-
return self.matcher(key[len(IDENT_PREFIX) :])
284+
def __getitem__(self, key: str) -> MatcherNameAdapter:
285+
return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :])
181286

182287
def __iter__(self) -> Iterator[str]:
183288
raise NotImplementedError()
@@ -211,7 +316,7 @@ def compile(self, input: str) -> Expression:
211316
)
212317
return Expression(code)
213318

214-
def evaluate(self, matcher: Callable[[str], bool]) -> bool:
319+
def evaluate(self, matcher: MatcherCall) -> bool:
215320
"""Evaluate the match expression.
216321
217322
:param matcher:
@@ -220,5 +325,5 @@ def evaluate(self, matcher: Callable[[str], bool]) -> bool:
220325
221326
:returns: Whether the expression matches or not.
222327
"""
223-
ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))
328+
ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)))
224329
return ret

0 commit comments

Comments
 (0)