[syntax-errors] Improve error message and range for pre-PEP-614 decorator syntax errors (#16581)

## Summary

A small followup to https://github.com/astral-sh/ruff/pull/16386. We now
tell the user exactly what it was about their decorator that constituted
invalid syntax on Python <3.9, and the range now highlights the specific
sub-expression that is invalid rather than highlighting the whole
decorator

## Test Plan

Inline snapshots are updated, and new ones are added.
This commit is contained in:
Alex Waygood 2025-03-17 11:17:27 +00:00 committed by GitHub
parent 4da6936ec4
commit 38bfda94ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 574 additions and 19 deletions

View file

@ -601,7 +601,7 @@ pub enum UnsupportedSyntaxErrorKind {
/// [PEP 614]: https://peps.python.org/pep-0614/
/// [`dotted_name`]: https://docs.python.org/3.8/reference/compound_stmts.html#grammar-token-dotted-name
/// [decorator grammar]: https://docs.python.org/3/reference/compound_stmts.html#grammar-token-python-grammar-decorator
RelaxedDecorator,
RelaxedDecorator(RelaxedDecoratorError),
/// Represents the use of a [PEP 570] positional-only parameter before Python 3.8.
///
@ -768,7 +768,28 @@ impl Display for UnsupportedSyntaxError {
UnsupportedSyntaxErrorKind::StarTuple(StarTupleKind::Yield) => {
"Cannot use iterable unpacking in yield expressions"
}
UnsupportedSyntaxErrorKind::RelaxedDecorator => "Unsupported expression in decorators",
UnsupportedSyntaxErrorKind::RelaxedDecorator(relaxed_decorator_error) => {
return match relaxed_decorator_error {
RelaxedDecoratorError::CallExpression => {
write!(
f,
"Cannot use a call expression in a decorator on Python {} \
unless it is the top-level expression or it occurs \
in the argument list of a top-level call expression \
(relaxed decorator syntax was {changed})",
self.target_version,
changed = self.kind.changed_version(),
)
}
RelaxedDecoratorError::Other(description) => write!(
f,
"Cannot use {description} outside function call arguments in a decorator on Python {} \
(syntax was {changed})",
self.target_version,
changed = self.kind.changed_version(),
),
}
}
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
"Cannot use positional-only parameter separator"
}
@ -795,6 +816,12 @@ impl Display for UnsupportedSyntaxError {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RelaxedDecoratorError {
CallExpression,
Other(&'static str),
}
/// Represents the kind of change in Python syntax between versions.
enum Change {
Added(PythonVersion),
@ -822,7 +849,9 @@ impl UnsupportedSyntaxErrorKind {
Change::Added(PythonVersion::PY39)
}
UnsupportedSyntaxErrorKind::StarTuple(_) => Change::Added(PythonVersion::PY38),
UnsupportedSyntaxErrorKind::RelaxedDecorator => Change::Added(PythonVersion::PY39),
UnsupportedSyntaxErrorKind::RelaxedDecorator { .. } => {
Change::Added(PythonVersion::PY39)
}
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
Change::Added(PythonVersion::PY38)
}

View file

@ -1,6 +1,7 @@
use ruff_python_ast::{self as ast, CmpOp, Expr, ExprContext};
use ruff_python_ast::{self as ast, CmpOp, Expr, ExprContext, Number};
use ruff_text_size::{Ranged, TextRange};
use crate::TokenKind;
use crate::{error::RelaxedDecoratorError, TokenKind};
/// Set the `ctx` for `Expr::Id`, `Expr::Attribute`, `Expr::Subscript`, `Expr::Starred`,
/// `Expr::Tuple` and `Expr::List`. If `expr` is either `Expr::Tuple` or `Expr::List`,
@ -47,11 +48,56 @@ pub(super) const fn token_kind_to_cmp_op(tokens: [TokenKind; 2]) -> Option<CmpOp
/// Helper for `parse_decorators` to determine if `expr` is a [`dotted_name`] from the decorator
/// grammar before Python 3.9.
///
/// Returns `Some((error, range))` if `expr` is not a `dotted_name`, or `None` if it is a `dotted_name`.
///
/// [`dotted_name`]: https://docs.python.org/3.8/reference/compound_stmts.html#grammar-token-dotted-name
pub(super) fn is_name_or_attribute_expression(expr: &Expr) -> bool {
match expr {
Expr::Attribute(attr) => is_name_or_attribute_expression(&attr.value),
Expr::Name(_) => true,
_ => false,
}
pub(super) fn detect_invalid_pre_py39_decorator_node(
expr: &Expr,
) -> Option<(RelaxedDecoratorError, TextRange)> {
let description = match expr {
Expr::Name(_) => return None,
Expr::Attribute(attribute) => {
return detect_invalid_pre_py39_decorator_node(&attribute.value)
}
Expr::Call(_) => return Some((RelaxedDecoratorError::CallExpression, expr.range())),
Expr::NumberLiteral(number) => match &number.value {
Number::Int(_) => "an int literal",
Number::Float(_) => "a float literal",
Number::Complex { .. } => "a complex literal",
},
Expr::BoolOp(_) => "boolean expression",
Expr::BinOp(_) => "binary-operation expression",
Expr::UnaryOp(_) => "unary-operation expression",
Expr::Await(_) => "`await` expression",
Expr::Lambda(_) => "lambda expression",
Expr::If(_) => "conditional expression",
Expr::Dict(_) => "a dict literal",
Expr::Set(_) => "a set literal",
Expr::List(_) => "a list literal",
Expr::Tuple(_) => "a tuple literal",
Expr::Starred(_) => "starred expression",
Expr::Slice(_) => "slice expression",
Expr::BytesLiteral(_) => "a bytes literal",
Expr::StringLiteral(_) => "a string literal",
Expr::EllipsisLiteral(_) => "an ellipsis literal",
Expr::NoneLiteral(_) => "a `None` literal",
Expr::BooleanLiteral(_) => "a boolean literal",
Expr::ListComp(_) => "a list comprehension",
Expr::SetComp(_) => "a set comprehension",
Expr::DictComp(_) => "a dict comprehension",
Expr::Generator(_) => "generator expression",
Expr::Yield(_) => "`yield` expression",
Expr::YieldFrom(_) => "`yield from` expression",
Expr::Compare(_) => "comparison expression",
Expr::FString(_) => "f-string",
Expr::Named(_) => "assignment expression",
Expr::Subscript(_) => "subscript expression",
Expr::IpyEscapeCommand(_) => "IPython escape command",
};
Some((RelaxedDecoratorError::Other(description), expr.range()))
}

View file

@ -2705,17 +2705,45 @@ impl<'src> Parser<'src> {
// # parse_options: { "target-version": "3.7" }
// @(x := lambda x: x)(foo)
// def bar(): ...
let allowed_decorator = match &parsed_expr.expr {
// test_err decorator_dict_literal_py38
// # parse_options: { "target-version": "3.8" }
// @{3: 3}
// def bar(): ...
// test_err decorator_float_literal_py38
// # parse_options: { "target-version": "3.8" }
// @3.14
// def bar(): ...
// test_ok decorator_await_expression_py39
// # parse_options: { "target-version": "3.9" }
// async def foo():
// @await bar
// def baz(): ...
// test_err decorator_await_expression_py38
// # parse_options: { "target-version": "3.8" }
// async def foo():
// @await bar
// def baz(): ...
// test_err decorator_non_toplevel_call_expression_py38
// # parse_options: { "target-version": "3.8" }
// @foo().bar()
// def baz(): ...
let relaxed_decorator_error = match &parsed_expr.expr {
Expr::Call(expr_call) => {
helpers::is_name_or_attribute_expression(&expr_call.func)
helpers::detect_invalid_pre_py39_decorator_node(&expr_call.func)
}
expr => helpers::is_name_or_attribute_expression(expr),
expr => helpers::detect_invalid_pre_py39_decorator_node(expr),
};
if !allowed_decorator {
if let Some((error, range)) = relaxed_decorator_error {
self.add_unsupported_syntax_error(
UnsupportedSyntaxErrorKind::RelaxedDecorator,
parsed_expr.range(),
UnsupportedSyntaxErrorKind::RelaxedDecorator(error),
range,
);
}
}