Skip to content

Commit 7ab0232

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 106a8fc commit 7ab0232

File tree

3 files changed

+32
-42
lines changed

3 files changed

+32
-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: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import keyword
3232
import re
3333
import types
34+
from typing import Final
3435
from typing import Literal
3536
from typing import NoReturn
3637
from typing import overload
@@ -39,10 +40,13 @@
3940

4041
__all__ = [
4142
"Expression",
42-
"ParseError",
43+
"ExpressionMatcher",
4344
]
4445

4546

47+
FILE_NAME: Final = "<pytest match expression>"
48+
49+
4650
class TokenType(enum.Enum):
4751
LPAREN = "left parenthesis"
4852
RPAREN = "right parenthesis"
@@ -64,25 +68,11 @@ class Token:
6468
pos: int
6569

6670

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

8574
def __init__(self, input: str) -> None:
75+
self.input = input
8676
self.tokens = self.lex(input)
8777
self.current = next(self.tokens)
8878

@@ -106,15 +96,15 @@ def lex(self, input: str) -> Iterator[Token]:
10696
elif (quote_char := input[pos]) in ("'", '"'):
10797
end_quote_pos = input.find(quote_char, pos + 1)
10898
if end_quote_pos == -1:
109-
raise ParseError(
110-
pos + 1,
99+
raise SyntaxError(
111100
f'closing quote "{quote_char}" is missing',
101+
(FILE_NAME, 1, pos + 1, input),
112102
)
113103
value = input[pos : end_quote_pos + 1]
114104
if (backslash_pos := input.find("\\")) != -1:
115-
raise ParseError(
116-
backslash_pos + 1,
105+
raise SyntaxError(
117106
r'escaping with "\" not supported in marker expression',
107+
(FILE_NAME, 1, backslash_pos + 1, input),
118108
)
119109
yield Token(TokenType.STRING, value, pos)
120110
pos += len(value)
@@ -132,9 +122,9 @@ def lex(self, input: str) -> Iterator[Token]:
132122
yield Token(TokenType.IDENT, value, pos)
133123
pos += len(value)
134124
else:
135-
raise ParseError(
136-
pos + 1,
125+
raise SyntaxError(
137126
f'unexpected character "{input[pos]}"',
127+
(FILE_NAME, 1, pos + 1, input),
138128
)
139129
yield Token(TokenType.EOF, "", pos)
140130

@@ -157,12 +147,12 @@ def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
157147
return None
158148

159149
def reject(self, expected: Sequence[TokenType]) -> NoReturn:
160-
raise ParseError(
161-
self.current.pos + 1,
150+
raise SyntaxError(
162151
"expected {}; got {}".format(
163152
" OR ".join(type.value for type in expected),
164153
self.current.type.value,
165154
),
155+
(FILE_NAME, 1, self.current.pos + 1, self.input),
166156
)
167157

168158

@@ -223,14 +213,14 @@ def not_expr(s: Scanner) -> ast.expr:
223213
def single_kwarg(s: Scanner) -> ast.keyword:
224214
keyword_name = s.accept(TokenType.IDENT, reject=True)
225215
if not keyword_name.value.isidentifier():
226-
raise ParseError(
227-
keyword_name.pos + 1,
216+
raise SyntaxError(
228217
f"not a valid python identifier {keyword_name.value}",
218+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
229219
)
230220
if keyword.iskeyword(keyword_name.value):
231-
raise ParseError(
232-
keyword_name.pos + 1,
221+
raise SyntaxError(
233222
f"unexpected reserved python keyword `{keyword_name.value}`",
223+
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
234224
)
235225
s.accept(TokenType.EQUAL, reject=True)
236226

@@ -245,9 +235,9 @@ def single_kwarg(s: Scanner) -> ast.keyword:
245235
elif value_token.value in BUILTIN_MATCHERS:
246236
value = BUILTIN_MATCHERS[value_token.value]
247237
else:
248-
raise ParseError(
249-
value_token.pos + 1,
238+
raise SyntaxError(
250239
f'unexpected character/s "{value_token.value}"',
240+
(FILE_NAME, 1, value_token.pos + 1, s.input),
251241
)
252242

253243
ret = ast.keyword(keyword_name.value, ast.Constant(value))
@@ -333,7 +323,7 @@ def compile(cls, input: str) -> Expression:
333323
334324
:param input: The input expression - one line.
335325
336-
:raises ParseError: If the expression is malformed.
326+
:raises SyntaxError: If the expression is malformed.
337327
"""
338328
astexpr = expression(Scanner(input))
339329
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

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

246245

0 commit comments

Comments
 (0)