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
2728from typing import Mapping
2829from typing import NoReturn
30+ from typing import Protocol
2931from typing import Sequence
3032
3133
@@ -43,6 +45,9 @@ class TokenType(enum.Enum):
4345 NOT = "not"
4446 IDENT = "identifier"
4547 EOF = "end of input"
48+ EQUAL = "="
49+ STRING = "str"
50+ COMMA = ","
4651
4752
4853@dataclasses .dataclass (frozen = True )
@@ -86,6 +91,27 @@ def lex(self, input: str) -> Iterator[Token]:
8691 elif input [pos ] == ")" :
8792 yield Token (TokenType .RPAREN , ")" , pos )
8893 pos += 1
94+ elif input [pos ] == "=" :
95+ yield Token (TokenType .EQUAL , "=" , pos )
96+ pos += 1
97+ elif input [pos ] == "," :
98+ yield Token (TokenType .COMMA , "," , pos )
99+ pos += 1
100+ elif (quote_char := input [pos ]) == "'" or input [pos ] == '"' :
101+ quote_position = input [pos + 1 :].find (quote_char )
102+ if quote_position == - 1 :
103+ raise ParseError (
104+ pos + 1 ,
105+ f'closing quote "{ quote_char } " is missing' ,
106+ )
107+ value = input [pos : pos + 2 + quote_position ]
108+ if "\\ " in value :
109+ raise ParseError (
110+ pos + 1 ,
111+ "escaping not supported in marker expression" ,
112+ )
113+ yield Token (TokenType .STRING , value , pos )
114+ pos += len (value )
89115 else :
90116 match = re .match (r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+" , input [pos :])
91117 if match :
@@ -166,18 +192,84 @@ def not_expr(s: Scanner) -> ast.expr:
166192 return ret
167193 ident = s .accept (TokenType .IDENT )
168194 if ident :
169- return ast .Name (IDENT_PREFIX + ident .value , ast .Load ())
195+ name = ast .Name (IDENT_PREFIX + ident .value , ast .Load ())
196+ if s .accept (TokenType .LPAREN ):
197+ ret = ast .Call (func = name , args = [], keywords = all_kwargs (s ))
198+ s .accept (TokenType .RPAREN , reject = True )
199+ else :
200+ ret = name
201+ return ret
202+
170203 s .reject ((TokenType .NOT , TokenType .LPAREN , TokenType .IDENT ))
171204
172205
173- class MatcherAdapter (Mapping [str , bool ]):
206+ BUILTIN_MATCHERS = {"True" : True , "False" : False , "None" : None }
207+
208+
209+ def single_kwarg (s : Scanner ) -> ast .keyword :
210+ keyword_name = s .accept (TokenType .IDENT , reject = True )
211+ assert keyword_name is not None # for mypy
212+ if not keyword_name .value .isidentifier () or keyword .iskeyword (keyword_name .value ):
213+ raise ParseError (
214+ keyword_name .pos + 1 ,
215+ f'unexpected character/s "{ keyword_name .value } "' ,
216+ )
217+ s .accept (TokenType .EQUAL , reject = True )
218+
219+ if value_token := s .accept (TokenType .STRING ):
220+ value : str | int | bool | None = value_token .value [1 :- 1 ] # strip quotes
221+ else :
222+ value_token = s .accept (TokenType .IDENT , reject = True )
223+ assert value_token is not None # for mypy
224+ if (
225+ (number := value_token .value ).isdigit ()
226+ or number .startswith ("-" )
227+ and number [1 :].isdigit ()
228+ ):
229+ value = int (number )
230+ elif value_token .value in BUILTIN_MATCHERS :
231+ value = BUILTIN_MATCHERS [value_token .value ]
232+ else :
233+ raise ParseError (
234+ value_token .pos + 1 ,
235+ f'unexpected character/s "{ value_token .value } "' ,
236+ )
237+
238+ ret = ast .keyword (keyword_name .value , ast .Constant (value ))
239+ return ret
240+
241+
242+ def all_kwargs (s : Scanner ) -> list [ast .keyword ]:
243+ ret = [single_kwarg (s )]
244+ while s .accept (TokenType .COMMA ):
245+ ret .append (single_kwarg (s ))
246+ return ret
247+
248+
249+ class MatcherCall (Protocol ):
250+ def __call__ (self , name : str , / , ** kwargs : object ) -> bool : ...
251+
252+
253+ @dataclasses .dataclass
254+ class MatcherNameAdapter :
255+ matcher : MatcherCall
256+ name : str
257+
258+ def __bool__ (self ) -> bool :
259+ return self .matcher (self .name )
260+
261+ def __call__ (self , ** kwargs : object ) -> bool :
262+ return self .matcher (self .name , ** kwargs )
263+
264+
265+ class MatcherAdapter (Mapping [str , MatcherNameAdapter ]):
174266 """Adapts a matcher function to a locals mapping as required by eval()."""
175267
176- def __init__ (self , matcher : Callable [[ str ], bool ] ) -> None :
268+ def __init__ (self , matcher : MatcherCall ) -> None :
177269 self .matcher = matcher
178270
179- def __getitem__ (self , key : str ) -> bool :
180- return self .matcher ( key [len (IDENT_PREFIX ) :])
271+ def __getitem__ (self , key : str ) -> MatcherNameAdapter :
272+ return MatcherNameAdapter ( matcher = self .matcher , name = key [len (IDENT_PREFIX ) :])
181273
182274 def __iter__ (self ) -> Iterator [str ]:
183275 raise NotImplementedError ()
@@ -211,7 +303,7 @@ def compile(self, input: str) -> Expression:
211303 )
212304 return Expression (code )
213305
214- def evaluate (self , matcher : Callable [[ str ], bool ] ) -> bool :
306+ def evaluate (self , matcher : MatcherCall ) -> bool :
215307 """Evaluate the match expression.
216308
217309 :param matcher:
@@ -220,5 +312,5 @@ def evaluate(self, matcher: Callable[[str], bool]) -> bool:
220312
221313 :returns: Whether the expression matches or not.
222314 """
223- ret : bool = eval (self .code , {"__builtins__" : {}}, MatcherAdapter (matcher ))
315+ ret : bool = bool ( eval (self .code , {"__builtins__" : {}}, MatcherAdapter (matcher ) ))
224316 return ret
0 commit comments