Skip to content

[syntax-errors] Named expressions in decorators before Python 3.9 #16386

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# parse_options: { "target-version": "3.8" }
@buttons[0].clicked.connect
def spam(): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# parse_options: { "target-version": "3.7" }
@(x := lambda x: x)(foo)
def bar(): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# parse_options: { "target-version": "3.8" }
@buttons.clicked.connect
def spam(): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# parse_options: { "target-version": "3.8" }
@eval("buttons[0].clicked.connect")
def spam(): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# parse_options: { "target-version": "3.8" }
def _(x): return x
@_(buttons[0].clicked.connect)
def spam(): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# parse_options: { "target-version": "3.9" }
@buttons[0].clicked.connect
def spam(): ...
@(x := lambda x: x)(foo)
def bar(): ...
33 changes: 33 additions & 0 deletions crates/ruff_python_parser/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,37 @@ pub enum UnsupportedSyntaxErrorKind {
Match,
Walrus,
ExceptStar,
/// Represents the use of a "relaxed" [PEP 614] decorator before Python 3.9.
///
/// ## Examples
///
/// Prior to Python 3.9, decorators were defined to be [`dotted_name`]s, optionally followed by
/// an argument list. For example:
///
/// ```python
/// @buttons.clicked.connect
/// def foo(): ...
///
/// @buttons.clicked.connect(1, 2, 3)
/// def foo(): ...
/// ```
///
/// As pointed out in the PEP, this prevented reasonable extensions like subscripts:
///
/// ```python
/// buttons = [QPushButton(f'Button {i}') for i in range(10)]
///
/// @buttons[0].clicked.connect
/// def spam(): ...
/// ```
///
/// Python 3.9 removed these restrictions and expanded the [decorator grammar] to include any
/// assignment expression and include cases like the example above.
///
/// [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,
/// Represents the use of a [PEP 570] positional-only parameter before Python 3.8.
///
/// ## Examples
Expand Down Expand Up @@ -513,6 +544,7 @@ impl Display for UnsupportedSyntaxError {
UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement",
UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)",
UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`",
UnsupportedSyntaxErrorKind::RelaxedDecorator => "Unsupported expression in decorators",
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
"Cannot use positional-only parameter separator"
}
Expand All @@ -538,6 +570,7 @@ impl UnsupportedSyntaxErrorKind {
UnsupportedSyntaxErrorKind::Match => PythonVersion::PY310,
UnsupportedSyntaxErrorKind::Walrus => PythonVersion::PY38,
UnsupportedSyntaxErrorKind::ExceptStar => PythonVersion::PY311,
UnsupportedSyntaxErrorKind::RelaxedDecorator => PythonVersion::PY39,
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => PythonVersion::PY38,
UnsupportedSyntaxErrorKind::TypeParameterList => PythonVersion::PY312,
UnsupportedSyntaxErrorKind::TypeAliasStatement => PythonVersion::PY312,
Expand Down
2 changes: 1 addition & 1 deletion crates/ruff_python_parser/src/parser/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ impl<'src> Parser<'src> {
/// If the parser isn't position at a `(` token.
///
/// See: <https://docs.python.org/3/reference/expressions.html#calls>
fn parse_call_expression(&mut self, func: Expr, start: TextSize) -> ast::ExprCall {
pub(super) fn parse_call_expression(&mut self, func: Expr, start: TextSize) -> ast::ExprCall {
let arguments = self.parse_arguments();

ast::ExprCall {
Expand Down
12 changes: 12 additions & 0 deletions crates/ruff_python_parser/src/parser/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,15 @@ pub(super) const fn token_kind_to_cmp_op(tokens: [TokenKind; 2]) -> Option<CmpOp
_ => return None,
})
}

/// Helper for `parse_decorators` to determine if `expr` is a [`dotted_name`] from the decorator
/// grammar before Python 3.9.
///
/// [`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,
}
}
54 changes: 52 additions & 2 deletions crates/ruff_python_parser/src/parser/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use rustc_hash::{FxBuildHasher, FxHashSet};

use ruff_python_ast::name::Name;
use ruff_python_ast::{
self as ast, ExceptHandler, Expr, ExprContext, IpyEscapeKind, Operator, Stmt, WithItem,
self as ast, ExceptHandler, Expr, ExprContext, IpyEscapeKind, Operator, PythonVersion, Stmt,
WithItem,
};
use ruff_text_size::{Ranged, TextRange, TextSize};

Expand Down Expand Up @@ -2599,14 +2600,63 @@ impl<'src> Parser<'src> {
let decorator_start = self.node_start();
self.bump(TokenKind::At);

let parsed_expr = self.parse_named_expression_or_higher(ExpressionContext::default());

if self.options.target_version < PythonVersion::PY39 {
// test_ok decorator_expression_dotted_ident_py38
// # parse_options: { "target-version": "3.8" }
// @buttons.clicked.connect
// def spam(): ...

// test_ok decorator_expression_identity_hack_py38
// # parse_options: { "target-version": "3.8" }
// def _(x): return x
// @_(buttons[0].clicked.connect)
// def spam(): ...

// test_ok decorator_expression_eval_hack_py38
// # parse_options: { "target-version": "3.8" }
// @eval("buttons[0].clicked.connect")
// def spam(): ...

// test_ok decorator_expression_py39
// # parse_options: { "target-version": "3.9" }
// @buttons[0].clicked.connect
// def spam(): ...
// @(x := lambda x: x)(foo)
// def bar(): ...

// test_err decorator_expression_py38
// # parse_options: { "target-version": "3.8" }
// @buttons[0].clicked.connect
// def spam(): ...

// test_err decorator_named_expression_py37
// # parse_options: { "target-version": "3.7" }
// @(x := lambda x: x)(foo)
// def bar(): ...
let allowed_decorator = match &parsed_expr.expr {
Expr::Call(expr_call) => {
helpers::is_name_or_attribute_expression(&expr_call.func)
}
expr => helpers::is_name_or_attribute_expression(expr),
};

if !allowed_decorator {
self.add_unsupported_syntax_error(
UnsupportedSyntaxErrorKind::RelaxedDecorator,
parsed_expr.range(),
);
}
}

// test_err decorator_invalid_expression
// @*x
// @(*x)
// @((*x))
// @yield x
// @yield from x
// def foo(): ...
let parsed_expr = self.parse_named_expression_or_higher(ExpressionContext::default());

decorators.push(ast::Decorator {
expression: parsed_expr.expr,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/err/decorator_expression_py38.py
---
## AST

```
Module(
ModModule {
range: 0..89,
body: [
FunctionDef(
StmtFunctionDef {
range: 45..88,
is_async: false,
decorator_list: [
Decorator {
range: 45..72,
expression: Attribute(
ExprAttribute {
range: 46..72,
value: Attribute(
ExprAttribute {
range: 46..64,
value: Subscript(
ExprSubscript {
range: 46..56,
value: Name(
ExprName {
range: 46..53,
id: Name("buttons"),
ctx: Load,
},
),
slice: NumberLiteral(
ExprNumberLiteral {
range: 54..55,
value: Int(
0,
),
},
),
ctx: Load,
},
),
attr: Identifier {
id: Name("clicked"),
range: 57..64,
},
ctx: Load,
},
),
attr: Identifier {
id: Name("connect"),
range: 65..72,
},
ctx: Load,
},
),
},
],
name: Identifier {
id: Name("spam"),
range: 77..81,
},
type_params: None,
parameters: Parameters {
range: 81..83,
posonlyargs: [],
args: [],
vararg: None,
kwonlyargs: [],
kwarg: None,
},
returns: None,
body: [
Expr(
StmtExpr {
range: 85..88,
value: EllipsisLiteral(
ExprEllipsisLiteral {
range: 85..88,
},
),
},
),
],
},
),
],
},
)
```
## Unsupported Syntax Errors

|
1 | # parse_options: { "target-version": "3.8" }
2 | @buttons[0].clicked.connect
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Unsupported expression in decorators on Python 3.8 (syntax was added in Python 3.9)
3 | def spam(): ...
|
Loading
Loading