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
« 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`.
3The grammar is:
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|:|\+|-|\.|\[|\]|\\|/)+
11The semantics are:
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
28import attr
31__all__ = [
32 "Expression",
33 "ParseError",
34]
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"
47@attr.s(frozen=True, slots=True, auto_attribs=True)
48class Token:
49 type: TokenType
50 value: str
51 pos: int
54class ParseError(Exception):
55 """The expression contains invalid syntax.
57 :param column: The column in the line where the error occurred (1-based).
58 :param message: A description of the error.
59 """
61 def __init__(self, column: int, message: str) -> None:
62 self.column = column
63 self.message = message
65 def __str__(self) -> str:
66 return f"at column {self.column}: {self.message}"
69class Scanner:
70 __slots__ = ("tokens", "current")
72 def __init__(self, input: str) -> None:
73 self.tokens = self.lex(input)
74 self.current = next(self.tokens)
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)
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
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 )
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 = "$"
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))
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
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
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))
171class MatcherAdapter(Mapping[str, bool]):
172 """Adapts a matcher function to a locals mapping as required by eval()."""
174 def __init__(self, matcher: Callable[[str], bool]) -> None:
175 self.matcher = matcher
177 def __getitem__(self, key: str) -> bool:
178 return self.matcher(key[len(IDENT_PREFIX) :])
180 def __iter__(self) -> Iterator[str]:
181 raise NotImplementedError()
183 def __len__(self) -> int:
184 raise NotImplementedError()
187class Expression:
188 """A compiled match expression as used by -k and -m.
190 The expression can be evaluated against different matchers.
191 """
193 __slots__ = ("code",)
195 def __init__(self, code: types.CodeType) -> None:
196 self.code = code
198 @classmethod
199 def compile(self, input: str) -> "Expression":
200 """Compile a match expression.
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)
212 def evaluate(self, matcher: Callable[[str], bool]) -> bool:
213 """Evaluate the match expression.
215 :param matcher:
216 Given an identifier, should return whether it matches or not.
217 Should be prepared to handle arbitrary strings as input.
219 :returns: Whether the expression matches or not.
220 """
221 ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))
222 return ret