55expression: expr? EOF
66expr: and_expr ('or' and_expr)*
77and_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+
910ident: (\w|:|\+|-|\.|\[|\]|\\|/)+
1011
1112The semantics are:
2021import ast
2122import dataclasses
2223import enum
24+ import keyword
2325import re
2426import types
25- from typing import Callable
2627from typing import Iterator
28+ from typing import Literal
2729from typing import Mapping
2830from typing import NoReturn
31+ from typing import overload
32+ from typing import Protocol
2933from 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