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
2323from __future__ import annotations
3131import keyword
3232import re
3333import types
34+ from typing import Final
35+ from typing import final
3436from typing import Literal
3537from typing import NoReturn
3638from typing import overload
3941
4042__all__ = [
4143 "Expression" ,
42- "ParseError " ,
44+ "ExpressionMatcher " ,
4345]
4446
4547
48+ FILE_NAME : Final = "<pytest match expression>"
49+
50+
4651class 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-
8272class 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:
223214def 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
269283class 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:
280294class 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
296311class 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 )))
0 commit comments