Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1r""" 

2Evaluate match expressions, as used by `-k` and `-m`. 

3 

4The grammar is: 

5 

6expression: expr? EOF 

7expr: and_expr ('or' and_expr)* 

8and_expr: not_expr ('and' not_expr)* 

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

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

11 

12The semantics are: 

13 

14- Empty expression evaluates to False. 

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

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

17""" 

18import ast 

19import enum 

20import re 

21import types 

22from typing import Callable 

23from typing import Iterator 

24from typing import Mapping 

25from typing import Optional 

26from typing import Sequence 

27 

28import attr 

29 

30from _pytest.compat import TYPE_CHECKING 

31 

32if TYPE_CHECKING: 

33 from typing import NoReturn 

34 

35 

36__all__ = [ 

37 "Expression", 

38 "ParseError", 

39] 

40 

41 

42class TokenType(enum.Enum): 

43 LPAREN = "left parenthesis" 

44 RPAREN = "right parenthesis" 

45 OR = "or" 

46 AND = "and" 

47 NOT = "not" 

48 IDENT = "identifier" 

49 EOF = "end of input" 

50 

51 

52@attr.s(frozen=True, slots=True) 

53class Token: 

54 type = attr.ib(type=TokenType) 

55 value = attr.ib(type=str) 

56 pos = attr.ib(type=int) 

57 

58 

59class ParseError(Exception): 

60 """The expression contains invalid syntax. 

61 

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

63 :param message: A description of the error. 

64 """ 

65 

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

67 self.column = column 

68 self.message = message 

69 

70 def __str__(self) -> str: 

71 return "at column {}: {}".format(self.column, self.message) 

72 

73 

74class Scanner: 

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

76 

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

78 self.tokens = self.lex(input) 

79 self.current = next(self.tokens) 

80 

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

82 pos = 0 

83 while pos < len(input): 

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

85 pos += 1 

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

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

88 pos += 1 

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

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

91 pos += 1 

92 else: 

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

94 if match: 

95 value = match.group(0) 

96 if value == "or": 

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

98 elif value == "and": 

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

100 elif value == "not": 

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

102 else: 

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

104 pos += len(value) 

105 else: 

106 raise ParseError( 

107 pos + 1, 'unexpected character "{}"'.format(input[pos]), 

108 ) 

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

110 

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

112 if self.current.type is type: 

113 token = self.current 

114 if token.type is not TokenType.EOF: 

115 self.current = next(self.tokens) 

116 return token 

117 if reject: 

118 self.reject((type,)) 

119 return None 

120 

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

122 raise ParseError( 

123 self.current.pos + 1, 

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

125 " OR ".join(type.value for type in expected), self.current.type.value, 

126 ), 

127 ) 

128 

129 

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

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

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

133IDENT_PREFIX = "$" 

134 

135 

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

137 if s.accept(TokenType.EOF): 

138 ret = ast.NameConstant(False) # type: ast.expr 

139 else: 

140 ret = expr(s) 

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

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

143 

144 

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

146 ret = and_expr(s) 

147 while s.accept(TokenType.OR): 

148 rhs = and_expr(s) 

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

150 return ret 

151 

152 

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

154 ret = not_expr(s) 

155 while s.accept(TokenType.AND): 

156 rhs = not_expr(s) 

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

158 return ret 

159 

160 

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

162 if s.accept(TokenType.NOT): 

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

164 if s.accept(TokenType.LPAREN): 

165 ret = expr(s) 

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

167 return ret 

168 ident = s.accept(TokenType.IDENT) 

169 if ident: 

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

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

172 

173 

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

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

176 

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

178 self.matcher = matcher 

179 

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

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

182 

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

184 raise NotImplementedError() 

185 

186 def __len__(self) -> int: 

187 raise NotImplementedError() 

188 

189 

190class Expression: 

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

192 

193 The expression can be evaulated against different matchers. 

194 """ 

195 

196 __slots__ = ("code",) 

197 

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

199 self.code = code 

200 

201 @classmethod 

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

203 """Compile a match expression. 

204 

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

206 """ 

207 astexpr = expression(Scanner(input)) 

208 code = compile( 

209 astexpr, filename="<pytest match expression>", mode="eval", 

210 ) # type: types.CodeType 

211 return Expression(code) 

212 

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

214 """Evaluate the match expression. 

215 

216 :param matcher: 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 = eval( 

222 self.code, {"__builtins__": {}}, MatcherAdapter(matcher) 

223 ) # type: bool 

224 return ret