5
5
expression: expr? EOF
6
6
expr: and_expr ('or' and_expr)*
7
7
and_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
+
9
10
ident: (\w|:|\+|-|\.|\[|\]|\\|/)+
10
11
11
12
The semantics are:
20
21
import ast
21
22
import dataclasses
22
23
import enum
24
+ import keyword
23
25
import re
24
26
import types
25
- from typing import Callable
26
27
from typing import Iterator
28
+ from typing import Literal
27
29
from typing import Mapping
28
30
from typing import NoReturn
31
+ from typing import overload
32
+ from typing import Protocol
29
33
from typing import Sequence
30
34
31
35
@@ -43,6 +47,9 @@ class TokenType(enum.Enum):
43
47
NOT = "not"
44
48
IDENT = "identifier"
45
49
EOF = "end of input"
50
+ EQUAL = "="
51
+ STRING = "str"
52
+ COMMA = ","
46
53
47
54
48
55
@dataclasses .dataclass (frozen = True )
@@ -86,6 +93,27 @@ def lex(self, input: str) -> Iterator[Token]:
86
93
elif input [pos ] == ")" :
87
94
yield Token (TokenType .RPAREN , ")" , pos )
88
95
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 )
89
117
else :
90
118
match = re .match (r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+" , input [pos :])
91
119
if match :
@@ -106,6 +134,14 @@ def lex(self, input: str) -> Iterator[Token]:
106
134
)
107
135
yield Token (TokenType .EOF , "" , pos )
108
136
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
+
109
145
def accept (self , type : TokenType , * , reject : bool = False ) -> Token | None :
110
146
if self .current .type is type :
111
147
token = self .current
@@ -166,18 +202,87 @@ def not_expr(s: Scanner) -> ast.expr:
166
202
return ret
167
203
ident = s .accept (TokenType .IDENT )
168
204
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
+
170
213
s .reject ((TokenType .NOT , TokenType .LPAREN , TokenType .IDENT ))
171
214
172
215
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 ]):
174
279
"""Adapts a matcher function to a locals mapping as required by eval()."""
175
280
176
- def __init__ (self , matcher : Callable [[ str ], bool ] ) -> None :
281
+ def __init__ (self , matcher : MatcherCall ) -> None :
177
282
self .matcher = matcher
178
283
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 ) :])
181
286
182
287
def __iter__ (self ) -> Iterator [str ]:
183
288
raise NotImplementedError ()
@@ -211,7 +316,7 @@ def compile(self, input: str) -> Expression:
211
316
)
212
317
return Expression (code )
213
318
214
- def evaluate (self , matcher : Callable [[ str ], bool ] ) -> bool :
319
+ def evaluate (self , matcher : MatcherCall ) -> bool :
215
320
"""Evaluate the match expression.
216
321
217
322
:param matcher:
@@ -220,5 +325,5 @@ def evaluate(self, matcher: Callable[[str], bool]) -> bool:
220
325
221
326
:returns: Whether the expression matches or not.
222
327
"""
223
- ret : bool = eval (self .code , {"__builtins__" : {}}, MatcherAdapter (matcher ))
328
+ ret : bool = bool ( eval (self .code , {"__builtins__" : {}}, MatcherAdapter (matcher ) ))
224
329
return ret
0 commit comments