Skip to content

Commit d77cbfa

Browse files
committed
mark/expression: use SyntaxError instead of custom ParseError
If we are going to expose Expression, better to use a general exception, this way we can avoid exposing an extra type.
1 parent 64d7e2c commit d77cbfa

File tree

3 files changed

+31
-42
lines changed

3 files changed

+31
-42
lines changed

src/_pytest/mark/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from typing import TYPE_CHECKING
1111

1212
from .expression import Expression
13-
from .expression import ParseError
1413
from .structures import _HiddenParam
1514
from .structures import EMPTY_PARAMETERSET_OPTION
1615
from .structures import get_empty_parameterset_mark
@@ -274,8 +273,10 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
274273
def _parse_expression(expr: str, exc_message: str) -> Expression:
275274
try:
276275
return Expression.compile(expr)
277-
except ParseError as e:
278-
raise UsageError(f"{exc_message}: {expr}: {e}") from None
276+
except SyntaxError as e:
277+
raise UsageError(
278+
f"{exc_message}: {e.text}: at column {e.offset}: {e.msg}"
279+
) from None
279280

280281

281282
def pytest_collection_modifyitems(items: list[Item], config: Config) -> None:

src/_pytest/mark/expression.py

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@
4141

4242
__all__ = [
4343
"Expression",
44-
"ParseError",
44+
"ExpressionMatcher",
4545
]
4646

4747

48+
FILE_NAME: Final = "<pytest match expression>"
49+
50+
4851
class TokenType(enum.Enum):
4952
LPAREN = "left parenthesis"
5053
RPAREN = "right parenthesis"
@@ -66,25 +69,11 @@ class Token:
6669
pos: int
6770

6871

69-
class ParseError(Exception):
70-
"""The :class:`Expression` contains invalid syntax.
71-
72-
:param column: The column in the line where the error occurred (1-based).
73-
:param message: A description of the error.
74-
"""
75-
76-
def __init__(self, column: int, message: str) -> None:
77-
self.column = column
78-
self.message = message
79-
80-
def __str__(self) -> str:
81-
return f"at column {self.column}: {self.message}"
82-
83-
8472
class Scanner:
85-
__slots__ = ("current", "tokens")
73+
__slots__ = ("current", "input", "tokens")
8674

8775
def __init__(self, input: str) -> None:
76+
self.input = input
8877
self.tokens = self.lex(input)
8978
self.current = next(self.tokens)
9079

@@ -108,15 +97,15 @@ def lex(self, input: str) -> Iterator[Token]:
10897
elif (quote_char := input[pos]) in ("'", '"'):
10998
end_quote_pos = input.find(quote_char, pos + 1)
11099
if end_quote_pos == -1:
111-
raise ParseError(
112-
pos + 1,
100+
raise SyntaxError(
113101
f'closing quote "{quote_char}" is missing',
102+
(FILE_NAME, 1, pos + 1, input),
114103
)
115104
value = input[pos : end_quote_pos + 1]
116105
if (backslash_pos := input.find("\\")) != -1:
117-
raise ParseError(
118-
backslash_pos + 1,
106+
raise SyntaxError(
119107
r'escaping with "\" not supported in marker expression',
108+
(FILE_NAME, 1, backslash_pos + 1, input),
120109
)
121110
yield Token(TokenType.STRING, value, pos)
122111
pos += len(value)
@@ -134,9 +123,9 @@ def lex(self, input: str) -> Iterator[Token]:
134123
yield Token(TokenType.IDENT, value, pos)
135124
pos += len(value)
136125
else:
137-
raise ParseError(
138-
pos + 1,
126+
raise SyntaxError(
139127
f'unexpected character "{input[pos]}"',
128+
(FILE_NAME, 1, pos + 1, input),
140129
)
141130
yield Token(TokenType.EOF, "", pos)
142131

@@ -159,12 +148,12 @@ def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
159148
return None
160149

161150
def reject(self, expected: Sequence[TokenType]) -> NoReturn:
162-
raise ParseError(
163-
self.current.pos + 1,
151+
raise SyntaxError(
164152
"expected {}; got {}".format(
165153
" OR ".join(type.value for type in expected),
166154
self.current.type.value,
167155
),
156+
(FILE_NAME, 1, self.current.pos + 1, self.input),
168157
)
169158

170159

@@ -225,14 +214,14 @@ def not_expr(s: Scanner) -> ast.expr:
225214
def single_kwarg(s: Scanner) -> ast.keyword:
226215
keyword_name = s.accept(TokenType.IDENT, reject=True)
227216
if not keyword_name.value.isidentifier():
228-
raise ParseError(
229-
keyword_name.pos + 1,
217+
raise SyntaxError(
230218
f"not a valid python identifier {keyword_name.value}",
219+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
231220
)
232221
if keyword.iskeyword(keyword_name.value):
233-
raise ParseError(
234-
keyword_name.pos + 1,
222+
raise SyntaxError(
235223
f"unexpected reserved python keyword `{keyword_name.value}`",
224+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
236225
)
237226
s.accept(TokenType.EQUAL, reject=True)
238227

@@ -247,9 +236,9 @@ def single_kwarg(s: Scanner) -> ast.keyword:
247236
elif value_token.value in BUILTIN_MATCHERS:
248237
value = BUILTIN_MATCHERS[value_token.value]
249238
else:
250-
raise ParseError(
251-
value_token.pos + 1,
239+
raise SyntaxError(
252240
f'unexpected character/s "{value_token.value}"',
241+
(FILE_NAME, 1, value_token.pos + 1, s.input),
253242
)
254243

255244
ret = ast.keyword(keyword_name.value, ast.Constant(value))
@@ -338,7 +327,7 @@ def compile(cls, input: str) -> Expression:
338327
339328
:param input: The input expression - one line.
340329
341-
:raises ParseError: If the expression is malformed.
330+
:raises SyntaxError: If the expression is malformed.
342331
"""
343332
astexpr = expression(Scanner(input))
344333
code = compile(

testing/test_mark_expression.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from _pytest.mark import MarkMatcher
44
from _pytest.mark.expression import Expression
55
from _pytest.mark.expression import ExpressionMatcher
6-
from _pytest.mark.expression import ParseError
76
import pytest
87

98

@@ -84,7 +83,7 @@ def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
8483

8584
assert evaluate(r"\nfoo\n", matcher)
8685
assert not evaluate(r"foo", matcher)
87-
with pytest.raises(ParseError):
86+
with pytest.raises(SyntaxError):
8887
evaluate("\nfoo\n", matcher)
8988

9089

@@ -137,10 +136,10 @@ def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
137136
),
138137
)
139138
def test_syntax_errors(expr: str, column: int, message: str) -> None:
140-
with pytest.raises(ParseError) as excinfo:
139+
with pytest.raises(SyntaxError) as excinfo:
141140
evaluate(expr, lambda ident, /, **kwargs: True)
142-
assert excinfo.value.column == column
143-
assert excinfo.value.message == message
141+
assert excinfo.value.offset == column
142+
assert excinfo.value.msg == message
144143

145144

146145
@pytest.mark.parametrize(
@@ -204,7 +203,7 @@ def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
204203
),
205204
)
206205
def test_invalid_idents(ident: str) -> None:
207-
with pytest.raises(ParseError):
206+
with pytest.raises(SyntaxError):
208207
evaluate(ident, lambda ident, /, **kwargs: True)
209208

210209

@@ -241,7 +240,7 @@ def test_invalid_idents(ident: str) -> None:
241240
def test_invalid_kwarg_name_or_value(
242241
expr: str, expected_error_msg: str, mark_matcher: MarkMatcher
243242
) -> None:
244-
with pytest.raises(ParseError, match=expected_error_msg):
243+
with pytest.raises(SyntaxError, match=expected_error_msg):
245244
assert evaluate(expr, mark_matcher)
246245

247246

0 commit comments

Comments
 (0)