1 r"""Evaluate match expressions, as used by `-k` and `-m`.

2

3 The grammar is:

4

5 expression: expr? EOF

6 expr: and_expr ('or' and_expr)*

7 and_expr: not_expr ('and' not_expr)*

8 not_expr: 'not' not_expr | '(' expr ')' | ident

9 ident: (\w|:|\+|-|\.|\[|\]|\\|/)+

10

11 The semantics are:

12

13- Empty expression evaluates to False.

14- ident evaluates to True of False according to a provided matcher function.

15- or/and/not evaluate according to the usual boolean semantics.

16"""

17 import ast

18 import enum

19 import re

20 import types

21 from typing import Callable

22 from typing import Iterator

23 from typing import Mapping

24 from typing import NoReturn

25 from typing import Optional

26 from typing import Sequence

27

28 import attr

29

30

31__all__ = [

32 "Expression",

33 "ParseError",

34]

35

36

37 class TokenType(enum.Enum):

38 LPAREN = "left parenthesis"

39 RPAREN = "right parenthesis"

40 OR = "or"

41 AND = "and"

42 NOT = "not"

43 IDENT = "identifier"

44 EOF = "end of input"

45

46

47@attr.s(frozen=True, slots=True, auto_attribs=True)

48 class Token:

49 type: TokenType

50 value: str

51 pos: int

52

53

54 class ParseError(Exception):

55 """The expression contains invalid syntax.

56

57 :param column: The column in the line where the error occurred (1-based).

58 :param message: A description of the error.

59 """

60

61 def __init__(self, column: int, message: str) -> None:

62 self.column = column

63 self.message = message

64

65 def __str__(self) -> str:

66 return f"at column {self.column}: {self.message}"

67

68

69 class Scanner:

70 __slots__ = ("tokens", "current")

71

72 def __init__(self, input: str) -> None:

73 self.tokens = self.lex(input)

74 self.current = next(self.tokens)

75

76 def lex(self, input: str) -> Iterator[Token]:

77 pos = 0

78 while pos < len(input):

79 if input[pos] in (" ", "\t"):

80 pos += 1

81 elif input[pos] == "(":

82 yield Token(TokenType.LPAREN, "(", pos)

83 pos += 1

84 elif input[pos] == ")":

85 yield Token(TokenType.RPAREN, ")", pos)

86 pos += 1

87 else:

88 match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:])

89 if match:

90 value = match.group(0)

91 if value == "or":

92 yield Token(TokenType.OR, value, pos)

93 elif value == "and":

94 yield Token(TokenType.AND, value, pos)

95 elif value == "not":

96 yield Token(TokenType.NOT, value, pos)

97 else:

98 yield Token(TokenType.IDENT, value, pos)

99 pos += len(value)

100 else:

101 raise ParseError(

102 pos + 1,

103 f'unexpected character "{input[pos]}"',

104 )

105 yield Token(TokenType.EOF, "", pos)

106

107 def accept(self, type: TokenType, *, reject: bool = False) -> Optional[Token]:

108 if self.current.type is type:

109 token = self.current

110 if token.type is not TokenType.EOF:

111 self.current = next(self.tokens)

112 return token

113 if reject:

114 self.reject((type,))

115 return None

116

117 def reject(self, expected: Sequence[TokenType]) -> NoReturn:

118 raise ParseError(

119 self.current.pos + 1,

120 "expected {}; got {}".format(

121 " OR ".join(type.value for type in expected),

122 self.current.type.value,

123 ),

124 )

125

126

127# True, False and None are legal match expression identifiers,

128# but illegal as Python identifiers. To fix this, this prefix

129# is added to identifiers in the conversion to Python AST.

130 IDENT_PREFIX = "$"

131

132

133 def expression(s: Scanner) -> ast.Expression:

134 if s.accept(TokenType.EOF):

135 ret: ast.expr = ast.NameConstant(False)

136 else:

137 ret = expr(s)

138 s.accept(TokenType.EOF, reject=True)

139 return ast.fix_missing_locations(ast.Expression(ret))

140

141

142 def expr(s: Scanner) -> ast.expr:

143 ret = and_expr(s)

144 while s.accept(TokenType.OR):

145 rhs = and_expr(s)

146 ret = ast.BoolOp(ast.Or(), [ret, rhs])

147 return ret

148

149

150 def and_expr(s: Scanner) -> ast.expr:

151 ret = not_expr(s)

152 while s.accept(TokenType.AND):

153 rhs = not_expr(s)

154 ret = ast.BoolOp(ast.And(), [ret, rhs])

155 return ret

156

157

158 def not_expr(s: Scanner) -> ast.expr:

159 if s.accept(TokenType.NOT):

160 return ast.UnaryOp(ast.Not(), not_expr(s))

161 if s.accept(TokenType.LPAREN):

162 ret = expr(s)

163 s.accept(TokenType.RPAREN, reject=True)

164 return ret

165 ident = s.accept(TokenType.IDENT)

166 if ident:

167 return ast.Name(IDENT_PREFIX + ident.value, ast.Load())

168 s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))

169

170

171 class MatcherAdapter(Mapping[str, bool]):

172 """Adapts a matcher function to a locals mapping as required by eval()."""

173

174 def __init__(self, matcher: Callable[[str], bool]) -> None:

175 self.matcher = matcher

176

177 def __getitem__(self, key: str) -> bool:

178 return self.matcher(key[len(IDENT_PREFIX) :])

179

180 def __iter__(self) -> Iterator[str]:

181 raise NotImplementedError()

182

183 def __len__(self) -> int:

184 raise NotImplementedError()

185

186

187 class Expression:

188 """A compiled match expression as used by -k and -m.

189

190 The expression can be evaluated against different matchers.

191 """

192

193 __slots__ = ("code",)

194

195 def __init__(self, code: types.CodeType) -> None:

196 self.code = code

197

198 @classmethod

199 def compile(self, input: str) -> "Expression":

200 """Compile a match expression.

201

202 :param input: The input expression - one line.

203 """

204 astexpr = expression(Scanner(input))

205 code: types.CodeType = compile(

206 astexpr,

207 filename="<pytest match expression>",

208 mode="eval",

209 )

210 return Expression(code)

211

212 def evaluate(self, matcher: Callable[[str], bool]) -> bool:

213 """Evaluate the match expression.

214

215 :param matcher:

216 Given an identifier, should return whether it matches or not.

217 Should be prepared to handle arbitrary strings as input.

218

219 :returns: Whether the expression matches or not.

220 """

221 ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))

222 return ret