Skip to content

Commit f1e3fee

Browse files
authored
Merge pull request #13790 from bluetech/expression-lipstick
mark/expression: some cleanups in preparation for possibly exposing publicly
2 parents 3d95c2f + d77cbfa commit f1e3fee

File tree

3 files changed

+100
-71
lines changed

3 files changed

+100
-71
lines changed

src/_pytest/mark/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from typing import TYPE_CHECKING
1111

1212
from .expression import Expression
13-
from .expression import ParseError
1413
from .structures import _HiddenParam
1514
from .structures import EMPTY_PARAMETERSET_OPTION
1615
from .structures import get_empty_parameterset_mark
@@ -274,8 +273,10 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
274273
def _parse_expression(expr: str, exc_message: str) -> Expression:
275274
try:
276275
return Expression.compile(expr)
277-
except ParseError as e:
278-
raise UsageError(f"{exc_message}: {expr}: {e}") from None
276+
except SyntaxError as e:
277+
raise UsageError(
278+
f"{exc_message}: {e.text}: at column {e.offset}: {e.msg}"
279+
) from None
279280

280281

281282
def pytest_collection_modifyitems(items: list[Item], config: Config) -> None:

src/_pytest/mark/expression.py

Lines changed: 67 additions & 45 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
@@ -39,10 +41,13 @@
3941

4042
__all__ = [
4143
"Expression",
42-
"ParseError",
44+
"ExpressionMatcher",
4345
]
4446

4547

48+
FILE_NAME: Final = "<pytest match expression>"
49+
50+
4651
class TokenType(enum.Enum):
4752
LPAREN = "left parenthesis"
4853
RPAREN = "right parenthesis"
@@ -64,25 +69,11 @@ class Token:
6469
pos: int
6570

6671

67-
class ParseError(Exception):
68-
"""The expression contains invalid syntax.
69-
70-
:param column: The column in the line where the error occurred (1-based).
71-
:param message: A description of the error.
72-
"""
73-
74-
def __init__(self, column: int, message: str) -> None:
75-
self.column = column
76-
self.message = message
77-
78-
def __str__(self) -> str:
79-
return f"at column {self.column}: {self.message}"
80-
81-
8272
class Scanner:
83-
__slots__ = ("current", "tokens")
73+
__slots__ = ("current", "input", "tokens")
8474

8575
def __init__(self, input: str) -> None:
76+
self.input = input
8677
self.tokens = self.lex(input)
8778
self.current = next(self.tokens)
8879

@@ -106,15 +97,15 @@ def lex(self, input: str) -> Iterator[Token]:
10697
elif (quote_char := input[pos]) in ("'", '"'):
10798
end_quote_pos = input.find(quote_char, pos + 1)
10899
if end_quote_pos == -1:
109-
raise ParseError(
110-
pos + 1,
100+
raise SyntaxError(
111101
f'closing quote "{quote_char}" is missing',
102+
(FILE_NAME, 1, pos + 1, input),
112103
)
113104
value = input[pos : end_quote_pos + 1]
114105
if (backslash_pos := input.find("\\")) != -1:
115-
raise ParseError(
116-
backslash_pos + 1,
106+
raise SyntaxError(
117107
r'escaping with "\" not supported in marker expression',
108+
(FILE_NAME, 1, backslash_pos + 1, input),
118109
)
119110
yield Token(TokenType.STRING, value, pos)
120111
pos += len(value)
@@ -132,9 +123,9 @@ def lex(self, input: str) -> Iterator[Token]:
132123
yield Token(TokenType.IDENT, value, pos)
133124
pos += len(value)
134125
else:
135-
raise ParseError(
136-
pos + 1,
126+
raise SyntaxError(
137127
f'unexpected character "{input[pos]}"',
128+
(FILE_NAME, 1, pos + 1, input),
138129
)
139130
yield Token(TokenType.EOF, "", pos)
140131

@@ -157,12 +148,12 @@ def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
157148
return None
158149

159150
def reject(self, expected: Sequence[TokenType]) -> NoReturn:
160-
raise ParseError(
161-
self.current.pos + 1,
151+
raise SyntaxError(
162152
"expected {}; got {}".format(
163153
" OR ".join(type.value for type in expected),
164154
self.current.type.value,
165155
),
156+
(FILE_NAME, 1, self.current.pos + 1, self.input),
166157
)
167158

168159

@@ -223,14 +214,14 @@ def not_expr(s: Scanner) -> ast.expr:
223214
def single_kwarg(s: Scanner) -> ast.keyword:
224215
keyword_name = s.accept(TokenType.IDENT, reject=True)
225216
if not keyword_name.value.isidentifier():
226-
raise ParseError(
227-
keyword_name.pos + 1,
217+
raise SyntaxError(
228218
f"not a valid python identifier {keyword_name.value}",
219+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
229220
)
230221
if keyword.iskeyword(keyword_name.value):
231-
raise ParseError(
232-
keyword_name.pos + 1,
222+
raise SyntaxError(
233223
f"unexpected reserved python keyword `{keyword_name.value}`",
224+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
234225
)
235226
s.accept(TokenType.EQUAL, reject=True)
236227

@@ -245,9 +236,9 @@ def single_kwarg(s: Scanner) -> ast.keyword:
245236
elif value_token.value in BUILTIN_MATCHERS:
246237
value = BUILTIN_MATCHERS[value_token.value]
247238
else:
248-
raise ParseError(
249-
value_token.pos + 1,
239+
raise SyntaxError(
250240
f'unexpected character/s "{value_token.value}"',
241+
(FILE_NAME, 1, value_token.pos + 1, s.input),
251242
)
252243

253244
ret = ast.keyword(keyword_name.value, ast.Constant(value))
@@ -261,13 +252,36 @@ def all_kwargs(s: Scanner) -> list[ast.keyword]:
261252
return ret
262253

263254

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

267281

268282
@dataclasses.dataclass
269283
class MatcherNameAdapter:
270-
matcher: MatcherCall
284+
matcher: ExpressionMatcher
271285
name: str
272286

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

283-
def __init__(self, matcher: MatcherCall) -> None:
297+
def __init__(self, matcher: ExpressionMatcher) -> None:
284298
self.matcher = matcher
285299

286300
def __getitem__(self, key: str) -> MatcherNameAdapter:
@@ -293,39 +307,47 @@ def __len__(self) -> int:
293307
raise NotImplementedError()
294308

295309

310+
@final
296311
class Expression:
297312
"""A compiled match expression as used by -k and -m.
298313
299314
The expression can be evaluated against different matchers.
300315
"""
301316

302-
__slots__ = ("code",)
317+
__slots__ = ("_code", "input")
303318

304-
def __init__(self, code: types.CodeType) -> None:
305-
self.code = code
319+
def __init__(self, input: str, code: types.CodeType) -> None:
320+
#: The original input line, as a string.
321+
self.input: Final = input
322+
self._code: Final = code
306323

307324
@classmethod
308325
def compile(cls, input: str) -> Expression:
309326
"""Compile a match expression.
310327
311328
:param input: The input expression - one line.
329+
330+
:raises SyntaxError: If the expression is malformed.
312331
"""
313332
astexpr = expression(Scanner(input))
314-
code: types.CodeType = compile(
333+
code = compile(
315334
astexpr,
316335
filename="<pytest match expression>",
317336
mode="eval",
318337
)
319-
return Expression(code)
338+
return Expression(input, code)
320339

321-
def evaluate(self, matcher: MatcherCall) -> bool:
340+
def evaluate(self, matcher: ExpressionMatcher) -> bool:
322341
"""Evaluate the match expression.
323342
324343
:param matcher:
325-
Given an identifier, should return whether it matches or not.
326-
Should be prepared to handle arbitrary strings as input.
344+
A callback which determines whether an identifier matches or not.
345+
See the :class:`ExpressionMatcher` protocol for details and example.
327346
328347
:returns: Whether the expression matches or not.
348+
349+
:raises UsageError:
350+
If the matcher doesn't support the expression. Cannot happen if the
351+
matcher supports all expressions.
329352
"""
330-
ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)))
331-
return ret
353+
return bool(eval(self._code, {"__builtins__": {}}, MatcherAdapter(matcher)))

testing/test_mark_expression.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
from __future__ import annotations
22

3-
from collections.abc import Callable
4-
from typing import cast
5-
63
from _pytest.mark import MarkMatcher
74
from _pytest.mark.expression import Expression
8-
from _pytest.mark.expression import MatcherCall
9-
from _pytest.mark.expression import ParseError
5+
from _pytest.mark.expression import ExpressionMatcher
106
import pytest
117

128

13-
def evaluate(input: str, matcher: Callable[[str], bool]) -> bool:
14-
return Expression.compile(input).evaluate(cast(MatcherCall, matcher))
9+
def evaluate(input: str, matcher: ExpressionMatcher) -> bool:
10+
return Expression.compile(input).evaluate(matcher)
1511

1612

1713
def test_empty_is_false() -> None:
18-
assert not evaluate("", lambda ident: False)
19-
assert not evaluate("", lambda ident: True)
20-
assert not evaluate(" ", lambda ident: False)
21-
assert not evaluate("\t", lambda ident: False)
14+
assert not evaluate("", lambda ident, /, **kwargs: False)
15+
assert not evaluate("", lambda ident, /, **kwargs: True)
16+
assert not evaluate(" ", lambda ident, /, **kwargs: False)
17+
assert not evaluate("\t", lambda ident, /, **kwargs: False)
2218

2319

2420
@pytest.mark.parametrize(
@@ -51,7 +47,9 @@ def test_empty_is_false() -> None:
5147
),
5248
)
5349
def test_basic(expr: str, expected: bool) -> None:
54-
matcher = {"true": True, "false": False}.__getitem__
50+
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
51+
return {"true": True, "false": False}[name]
52+
5553
assert evaluate(expr, matcher) is expected
5654

5755

@@ -67,7 +65,9 @@ def test_basic(expr: str, expected: bool) -> None:
6765
),
6866
)
6967
def test_syntax_oddities(expr: str, expected: bool) -> None:
70-
matcher = {"true": True, "false": False}.__getitem__
68+
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
69+
return {"true": True, "false": False}[name]
70+
7171
assert evaluate(expr, matcher) is expected
7272

7373

@@ -77,11 +77,13 @@ def test_backslash_not_treated_specially() -> None:
7777
user will never need to insert a literal newline, only \n (two chars). So
7878
mark expressions themselves do not support escaping, instead they treat
7979
backslashes as regular identifier characters."""
80-
matcher = {r"\nfoo\n"}.__contains__
80+
81+
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
82+
return {r"\nfoo\n"}.__contains__(name)
8183

8284
assert evaluate(r"\nfoo\n", matcher)
8385
assert not evaluate(r"foo", matcher)
84-
with pytest.raises(ParseError):
86+
with pytest.raises(SyntaxError):
8587
evaluate("\nfoo\n", matcher)
8688

8789

@@ -134,10 +136,10 @@ def test_backslash_not_treated_specially() -> None:
134136
),
135137
)
136138
def test_syntax_errors(expr: str, column: int, message: str) -> None:
137-
with pytest.raises(ParseError) as excinfo:
138-
evaluate(expr, lambda ident: True)
139-
assert excinfo.value.column == column
140-
assert excinfo.value.message == message
139+
with pytest.raises(SyntaxError) as excinfo:
140+
evaluate(expr, lambda ident, /, **kwargs: True)
141+
assert excinfo.value.offset == column
142+
assert excinfo.value.msg == message
141143

142144

143145
@pytest.mark.parametrize(
@@ -172,7 +174,10 @@ def test_syntax_errors(expr: str, column: int, message: str) -> None:
172174
),
173175
)
174176
def test_valid_idents(ident: str) -> None:
175-
assert evaluate(ident, {ident: True}.__getitem__)
177+
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
178+
return name == ident
179+
180+
assert evaluate(ident, matcher)
176181

177182

178183
@pytest.mark.parametrize(
@@ -198,13 +203,14 @@ def test_valid_idents(ident: str) -> None:
198203
),
199204
)
200205
def test_invalid_idents(ident: str) -> None:
201-
with pytest.raises(ParseError):
202-
evaluate(ident, lambda ident: True)
206+
with pytest.raises(SyntaxError):
207+
evaluate(ident, lambda ident, /, **kwargs: True)
203208

204209

205210
@pytest.mark.parametrize(
206211
"expr, expected_error_msg",
207212
(
213+
("mark()", "expected identifier; got right parenthesis"),
208214
("mark(True=False)", "unexpected reserved python keyword `True`"),
209215
("mark(def=False)", "unexpected reserved python keyword `def`"),
210216
("mark(class=False)", "unexpected reserved python keyword `class`"),
@@ -234,7 +240,7 @@ def test_invalid_idents(ident: str) -> None:
234240
def test_invalid_kwarg_name_or_value(
235241
expr: str, expected_error_msg: str, mark_matcher: MarkMatcher
236242
) -> None:
237-
with pytest.raises(ParseError, match=expected_error_msg):
243+
with pytest.raises(SyntaxError, match=expected_error_msg):
238244
assert evaluate(expr, mark_matcher)
239245

240246

0 commit comments

Comments
 (0)