Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/_pytest/mark/expression.py : 37%

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`.
4The grammar is:
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|:|\+|-|\.|\[|\])+
12The semantics are:
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
28import attr
30from _pytest.compat import TYPE_CHECKING
32if TYPE_CHECKING:
33 from typing import NoReturn
36__all__ = [
37 "Expression",
38 "ParseError",
39]
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"
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)
59class ParseError(Exception):
60 """The expression contains invalid syntax.
62 :param column: The column in the line where the error occurred (1-based).
63 :param message: A description of the error.
64 """
66 def __init__(self, column: int, message: str) -> None:
67 self.column = column
68 self.message = message
70 def __str__(self) -> str:
71 return "at column {}: {}".format(self.column, self.message)
74class Scanner:
75 __slots__ = ("tokens", "current")
77 def __init__(self, input: str) -> None:
78 self.tokens = self.lex(input)
79 self.current = next(self.tokens)
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)
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
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 )
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 = "$"
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))
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
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
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))
174class MatcherAdapter(Mapping[str, bool]):
175 """Adapts a matcher function to a locals mapping as required by eval()."""
177 def __init__(self, matcher: Callable[[str], bool]) -> None:
178 self.matcher = matcher
180 def __getitem__(self, key: str) -> bool:
181 return self.matcher(key[len(IDENT_PREFIX) :])
183 def __iter__(self) -> Iterator[str]:
184 raise NotImplementedError()
186 def __len__(self) -> int:
187 raise NotImplementedError()
190class Expression:
191 """A compiled match expression as used by -k and -m.
193 The expression can be evaulated against different matchers.
194 """
196 __slots__ = ("code",)
198 def __init__(self, code: types.CodeType) -> None:
199 self.code = code
201 @classmethod
202 def compile(self, input: str) -> "Expression":
203 """Compile a match expression.
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)
213 def evaluate(self, matcher: Callable[[str], bool]) -> bool:
214 """Evaluate the match expression.
216 :param matcher: 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 = eval(
222 self.code, {"__builtins__": {}}, MatcherAdapter(matcher)
223 ) # type: bool
224 return ret