16
16
17
17
- Empty expression evaluates to False.
18
18
- ident evaluates to True or False according to a provided matcher function.
19
- - or/and/not evaluate according to the usual boolean semantics.
20
19
- 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.
21
21
"""
22
22
23
23
from __future__ import annotations
31
31
import keyword
32
32
import re
33
33
import types
34
+ from typing import Final
35
+ from typing import final
34
36
from typing import Literal
35
37
from typing import NoReturn
36
38
from typing import overload
39
41
40
42
__all__ = [
41
43
"Expression" ,
42
- "ParseError " ,
44
+ "ExpressionMatcher " ,
43
45
]
44
46
45
47
48
+ FILE_NAME : Final = "<pytest match expression>"
49
+
50
+
46
51
class TokenType (enum .Enum ):
47
52
LPAREN = "left parenthesis"
48
53
RPAREN = "right parenthesis"
@@ -64,25 +69,11 @@ class Token:
64
69
pos : int
65
70
66
71
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
-
82
72
class Scanner :
83
- __slots__ = ("current" , "tokens" )
73
+ __slots__ = ("current" , "input" , " tokens" )
84
74
85
75
def __init__ (self , input : str ) -> None :
76
+ self .input = input
86
77
self .tokens = self .lex (input )
87
78
self .current = next (self .tokens )
88
79
@@ -106,15 +97,15 @@ def lex(self, input: str) -> Iterator[Token]:
106
97
elif (quote_char := input [pos ]) in ("'" , '"' ):
107
98
end_quote_pos = input .find (quote_char , pos + 1 )
108
99
if end_quote_pos == - 1 :
109
- raise ParseError (
110
- pos + 1 ,
100
+ raise SyntaxError (
111
101
f'closing quote "{ quote_char } " is missing' ,
102
+ (FILE_NAME , 1 , pos + 1 , input ),
112
103
)
113
104
value = input [pos : end_quote_pos + 1 ]
114
105
if (backslash_pos := input .find ("\\ " )) != - 1 :
115
- raise ParseError (
116
- backslash_pos + 1 ,
106
+ raise SyntaxError (
117
107
r'escaping with "\" not supported in marker expression' ,
108
+ (FILE_NAME , 1 , backslash_pos + 1 , input ),
118
109
)
119
110
yield Token (TokenType .STRING , value , pos )
120
111
pos += len (value )
@@ -132,9 +123,9 @@ def lex(self, input: str) -> Iterator[Token]:
132
123
yield Token (TokenType .IDENT , value , pos )
133
124
pos += len (value )
134
125
else :
135
- raise ParseError (
136
- pos + 1 ,
126
+ raise SyntaxError (
137
127
f'unexpected character "{ input [pos ]} "' ,
128
+ (FILE_NAME , 1 , pos + 1 , input ),
138
129
)
139
130
yield Token (TokenType .EOF , "" , pos )
140
131
@@ -157,12 +148,12 @@ def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
157
148
return None
158
149
159
150
def reject (self , expected : Sequence [TokenType ]) -> NoReturn :
160
- raise ParseError (
161
- self .current .pos + 1 ,
151
+ raise SyntaxError (
162
152
"expected {}; got {}" .format (
163
153
" OR " .join (type .value for type in expected ),
164
154
self .current .type .value ,
165
155
),
156
+ (FILE_NAME , 1 , self .current .pos + 1 , self .input ),
166
157
)
167
158
168
159
@@ -223,14 +214,14 @@ def not_expr(s: Scanner) -> ast.expr:
223
214
def single_kwarg (s : Scanner ) -> ast .keyword :
224
215
keyword_name = s .accept (TokenType .IDENT , reject = True )
225
216
if not keyword_name .value .isidentifier ():
226
- raise ParseError (
227
- keyword_name .pos + 1 ,
217
+ raise SyntaxError (
228
218
f"not a valid python identifier { keyword_name .value } " ,
219
+ (FILE_NAME , 1 , keyword_name .pos + 1 , s .input ),
229
220
)
230
221
if keyword .iskeyword (keyword_name .value ):
231
- raise ParseError (
232
- keyword_name .pos + 1 ,
222
+ raise SyntaxError (
233
223
f"unexpected reserved python keyword `{ keyword_name .value } `" ,
224
+ (FILE_NAME , 1 , keyword_name .pos + 1 , s .input ),
234
225
)
235
226
s .accept (TokenType .EQUAL , reject = True )
236
227
@@ -245,9 +236,9 @@ def single_kwarg(s: Scanner) -> ast.keyword:
245
236
elif value_token .value in BUILTIN_MATCHERS :
246
237
value = BUILTIN_MATCHERS [value_token .value ]
247
238
else :
248
- raise ParseError (
249
- value_token .pos + 1 ,
239
+ raise SyntaxError (
250
240
f'unexpected character/s "{ value_token .value } "' ,
241
+ (FILE_NAME , 1 , value_token .pos + 1 , s .input ),
251
242
)
252
243
253
244
ret = ast .keyword (keyword_name .value , ast .Constant (value ))
@@ -261,13 +252,36 @@ def all_kwargs(s: Scanner) -> list[ast.keyword]:
261
252
return ret
262
253
263
254
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
+
265
279
def __call__ (self , name : str , / , ** kwargs : str | int | bool | None ) -> bool : ...
266
280
267
281
268
282
@dataclasses .dataclass
269
283
class MatcherNameAdapter :
270
- matcher : MatcherCall
284
+ matcher : ExpressionMatcher
271
285
name : str
272
286
273
287
def __bool__ (self ) -> bool :
@@ -280,7 +294,7 @@ def __call__(self, **kwargs: str | int | bool | None) -> bool:
280
294
class MatcherAdapter (Mapping [str , MatcherNameAdapter ]):
281
295
"""Adapts a matcher function to a locals mapping as required by eval()."""
282
296
283
- def __init__ (self , matcher : MatcherCall ) -> None :
297
+ def __init__ (self , matcher : ExpressionMatcher ) -> None :
284
298
self .matcher = matcher
285
299
286
300
def __getitem__ (self , key : str ) -> MatcherNameAdapter :
@@ -293,39 +307,47 @@ def __len__(self) -> int:
293
307
raise NotImplementedError ()
294
308
295
309
310
+ @final
296
311
class Expression :
297
312
"""A compiled match expression as used by -k and -m.
298
313
299
314
The expression can be evaluated against different matchers.
300
315
"""
301
316
302
- __slots__ = ("code" , )
317
+ __slots__ = ("_code" , "input" )
303
318
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
306
323
307
324
@classmethod
308
325
def compile (cls , input : str ) -> Expression :
309
326
"""Compile a match expression.
310
327
311
328
:param input: The input expression - one line.
329
+
330
+ :raises SyntaxError: If the expression is malformed.
312
331
"""
313
332
astexpr = expression (Scanner (input ))
314
- code : types . CodeType = compile (
333
+ code = compile (
315
334
astexpr ,
316
335
filename = "<pytest match expression>" ,
317
336
mode = "eval" ,
318
337
)
319
- return Expression (code )
338
+ return Expression (input , code )
320
339
321
- def evaluate (self , matcher : MatcherCall ) -> bool :
340
+ def evaluate (self , matcher : ExpressionMatcher ) -> bool :
322
341
"""Evaluate the match expression.
323
342
324
343
: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 .
327
346
328
347
: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.
329
352
"""
330
- ret : bool = bool (eval (self .code , {"__builtins__" : {}}, MatcherAdapter (matcher )))
331
- return ret
353
+ return bool (eval (self ._code , {"__builtins__" : {}}, MatcherAdapter (matcher )))
0 commit comments