Skip to content

Commit 64d7e2c

Browse files
committed
mark/expression: documentation/naming tweaks
Make the module a little nicer ahead of possibly exposing it publicly.
1 parent 8d68586 commit 64d7e2c

File tree

2 files changed

+51
-17
lines changed

2 files changed

+51
-17
lines changed

src/_pytest/mark/expression.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
1717
- Empty expression evaluates to False.
1818
- ident evaluates to True or False according to a provided matcher function.
19-
- or/and/not evaluate according to the usual boolean semantics.
2019
- ident with parentheses and keyword arguments evaluates to True or False according to a provided matcher function.
20+
- or/and/not evaluate according to the usual boolean semantics.
2121
"""
2222

2323
from __future__ import annotations
@@ -31,6 +31,8 @@
3131
import keyword
3232
import re
3333
import types
34+
from typing import Final
35+
from typing import final
3436
from typing import Literal
3537
from typing import NoReturn
3638
from typing import overload
@@ -65,7 +67,7 @@ class Token:
6567

6668

6769
class ParseError(Exception):
68-
"""The expression contains invalid syntax.
70+
"""The :class:`Expression` contains invalid syntax.
6971
7072
:param column: The column in the line where the error occurred (1-based).
7173
:param message: A description of the error.
@@ -261,13 +263,36 @@ def all_kwargs(s: Scanner) -> list[ast.keyword]:
261263
return ret
262264

263265

264-
class MatcherCall(Protocol):
266+
class ExpressionMatcher(Protocol):
267+
"""A callable which, given an identifier and optional kwargs, should return
268+
whether it matches in an :class:`Expression` evaluation.
269+
270+
Should be prepared to handle arbitrary strings as input.
271+
272+
If no kwargs are provided, the expression of the form `foo`.
273+
If kwargs are provided, the expression is of the form `foo(1, b=True, "s")`.
274+
275+
If the expression is not supported (e.g. don't want to accept the kwargs
276+
syntax variant), should raise :class:`~pytest.UsageError`.
277+
278+
Example::
279+
280+
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
281+
# Match `cat`.
282+
if name == "cat" and not kwargs:
283+
return True
284+
# Match `dog(barks=True)`.
285+
if name == "dog" and kwargs == {"barks": False}:
286+
return True
287+
return False
288+
"""
289+
265290
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ...
266291

267292

268293
@dataclasses.dataclass
269294
class MatcherNameAdapter:
270-
matcher: MatcherCall
295+
matcher: ExpressionMatcher
271296
name: str
272297

273298
def __bool__(self) -> bool:
@@ -280,7 +305,7 @@ def __call__(self, **kwargs: str | int | bool | None) -> bool:
280305
class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
281306
"""Adapts a matcher function to a locals mapping as required by eval()."""
282307

283-
def __init__(self, matcher: MatcherCall) -> None:
308+
def __init__(self, matcher: ExpressionMatcher) -> None:
284309
self.matcher = matcher
285310

286311
def __getitem__(self, key: str) -> MatcherNameAdapter:
@@ -293,39 +318,47 @@ def __len__(self) -> int:
293318
raise NotImplementedError()
294319

295320

321+
@final
296322
class Expression:
297323
"""A compiled match expression as used by -k and -m.
298324
299325
The expression can be evaluated against different matchers.
300326
"""
301327

302-
__slots__ = ("code",)
328+
__slots__ = ("_code", "input")
303329

304-
def __init__(self, code: types.CodeType) -> None:
305-
self.code = code
330+
def __init__(self, input: str, code: types.CodeType) -> None:
331+
#: The original input line, as a string.
332+
self.input: Final = input
333+
self._code: Final = code
306334

307335
@classmethod
308336
def compile(cls, input: str) -> Expression:
309337
"""Compile a match expression.
310338
311339
:param input: The input expression - one line.
340+
341+
:raises ParseError: If the expression is malformed.
312342
"""
313343
astexpr = expression(Scanner(input))
314-
code: types.CodeType = compile(
344+
code = compile(
315345
astexpr,
316346
filename="<pytest match expression>",
317347
mode="eval",
318348
)
319-
return Expression(code)
349+
return Expression(input, code)
320350

321-
def evaluate(self, matcher: MatcherCall) -> bool:
351+
def evaluate(self, matcher: ExpressionMatcher) -> bool:
322352
"""Evaluate the match expression.
323353
324354
:param matcher:
325-
Given an identifier, should return whether it matches or not.
326-
Should be prepared to handle arbitrary strings as input.
355+
A callback which determines whether an identifier matches or not.
356+
See the :class:`ExpressionMatcher` protocol for details and example.
327357
328358
:returns: Whether the expression matches or not.
359+
360+
:raises UsageError:
361+
If the matcher doesn't support the expression. Cannot happen if the
362+
matcher supports all expressions.
329363
"""
330-
ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)))
331-
return ret
364+
return bool(eval(self._code, {"__builtins__": {}}, MatcherAdapter(matcher)))

testing/test_mark_expression.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from _pytest.mark import MarkMatcher
44
from _pytest.mark.expression import Expression
5-
from _pytest.mark.expression import MatcherCall
5+
from _pytest.mark.expression import ExpressionMatcher
66
from _pytest.mark.expression import ParseError
77
import pytest
88

99

10-
def evaluate(input: str, matcher: MatcherCall) -> bool:
10+
def evaluate(input: str, matcher: ExpressionMatcher) -> bool:
1111
return Expression.compile(input).evaluate(matcher)
1212

1313

@@ -211,6 +211,7 @@ def test_invalid_idents(ident: str) -> None:
211211
@pytest.mark.parametrize(
212212
"expr, expected_error_msg",
213213
(
214+
("mark()", "expected identifier; got right parenthesis"),
214215
("mark(True=False)", "unexpected reserved python keyword `True`"),
215216
("mark(def=False)", "unexpected reserved python keyword `def`"),
216217
("mark(class=False)", "unexpected reserved python keyword `class`"),

0 commit comments

Comments
 (0)