Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/mark/expression.py: 41%

122 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

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

2 

3The grammar is: 

4 

5expression: expr? EOF 

6expr: and_expr ('or' and_expr)* 

7and_expr: not_expr ('and' not_expr)* 

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

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

10 

11The 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""" 

17import ast 

18import enum 

19import re 

20import types 

21from typing import Callable 

22from typing import Iterator 

23from typing import Mapping 

24from typing import NoReturn 

25from typing import Optional 

26from typing import Sequence 

27 

28import attr 

29 

30 

31__all__ = [ 

32 "Expression", 

33 "ParseError", 

34] 

35 

36 

37class 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) 

48class Token: 

49 type: TokenType 

50 value: str 

51 pos: int 

52 

53 

54class 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 

69class 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. 

130IDENT_PREFIX = "$" 

131 

132 

133def 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 

142def 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 

150def 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 

158def 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 

171class 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 

187class 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