Skip to content

Commit 318f503

Browse files
authored
[syntax-errors] Named expressions in decorators before Python 3.9 (#16386)
Summary -- This PR detects the relaxed grammar for decorators proposed in [PEP 614](https://peps.python.org/pep-0614/) on Python 3.8 and lower. The 3.8 grammar for decorators is [here](https://docs.python.org/3.8/reference/compound_stmts.html#grammar-token-decorators): ``` decorators ::= decorator+ decorator ::= "@" dotted_name ["(" [argument_list [","]] ")"] NEWLINE dotted_name ::= identifier ("." identifier)* ``` in contrast to the current grammar [here](https://docs.python.org/3/reference/compound_stmts.html#grammar-token-python-grammar-decorators) ``` decorators ::= decorator+ decorator ::= "@" assignment_expression NEWLINE assignment_expression ::= [identifier ":="] expression ``` Test Plan -- New inline parser tests.
1 parent d062388 commit 318f503

16 files changed

+876
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# parse_options: { "target-version": "3.8" }
2+
@buttons[0].clicked.connect
3+
def spam(): ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# parse_options: { "target-version": "3.7" }
2+
@(x := lambda x: x)(foo)
3+
def bar(): ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# parse_options: { "target-version": "3.8" }
2+
@buttons.clicked.connect
3+
def spam(): ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# parse_options: { "target-version": "3.8" }
2+
@eval("buttons[0].clicked.connect")
3+
def spam(): ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# parse_options: { "target-version": "3.8" }
2+
def _(x): return x
3+
@_(buttons[0].clicked.connect)
4+
def spam(): ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# parse_options: { "target-version": "3.9" }
2+
@buttons[0].clicked.connect
3+
def spam(): ...
4+
@(x := lambda x: x)(foo)
5+
def bar(): ...

crates/ruff_python_parser/src/error.rs

+33
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,37 @@ pub enum UnsupportedSyntaxErrorKind {
449449
Match,
450450
Walrus,
451451
ExceptStar,
452+
/// Represents the use of a "relaxed" [PEP 614] decorator before Python 3.9.
453+
///
454+
/// ## Examples
455+
///
456+
/// Prior to Python 3.9, decorators were defined to be [`dotted_name`]s, optionally followed by
457+
/// an argument list. For example:
458+
///
459+
/// ```python
460+
/// @buttons.clicked.connect
461+
/// def foo(): ...
462+
///
463+
/// @buttons.clicked.connect(1, 2, 3)
464+
/// def foo(): ...
465+
/// ```
466+
///
467+
/// As pointed out in the PEP, this prevented reasonable extensions like subscripts:
468+
///
469+
/// ```python
470+
/// buttons = [QPushButton(f'Button {i}') for i in range(10)]
471+
///
472+
/// @buttons[0].clicked.connect
473+
/// def spam(): ...
474+
/// ```
475+
///
476+
/// Python 3.9 removed these restrictions and expanded the [decorator grammar] to include any
477+
/// assignment expression and include cases like the example above.
478+
///
479+
/// [PEP 614]: https://peps.python.org/pep-0614/
480+
/// [`dotted_name`]: https://docs.python.org/3.8/reference/compound_stmts.html#grammar-token-dotted-name
481+
/// [decorator grammar]: https://docs.python.org/3/reference/compound_stmts.html#grammar-token-python-grammar-decorator
482+
RelaxedDecorator,
452483
/// Represents the use of a [PEP 570] positional-only parameter before Python 3.8.
453484
///
454485
/// ## Examples
@@ -513,6 +544,7 @@ impl Display for UnsupportedSyntaxError {
513544
UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement",
514545
UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)",
515546
UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`",
547+
UnsupportedSyntaxErrorKind::RelaxedDecorator => "Unsupported expression in decorators",
516548
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
517549
"Cannot use positional-only parameter separator"
518550
}
@@ -538,6 +570,7 @@ impl UnsupportedSyntaxErrorKind {
538570
UnsupportedSyntaxErrorKind::Match => PythonVersion::PY310,
539571
UnsupportedSyntaxErrorKind::Walrus => PythonVersion::PY38,
540572
UnsupportedSyntaxErrorKind::ExceptStar => PythonVersion::PY311,
573+
UnsupportedSyntaxErrorKind::RelaxedDecorator => PythonVersion::PY39,
541574
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => PythonVersion::PY38,
542575
UnsupportedSyntaxErrorKind::TypeParameterList => PythonVersion::PY312,
543576
UnsupportedSyntaxErrorKind::TypeAliasStatement => PythonVersion::PY312,

crates/ruff_python_parser/src/parser/expression.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ impl<'src> Parser<'src> {
632632
/// If the parser isn't position at a `(` token.
633633
///
634634
/// See: <https://docs.python.org/3/reference/expressions.html#calls>
635-
fn parse_call_expression(&mut self, func: Expr, start: TextSize) -> ast::ExprCall {
635+
pub(super) fn parse_call_expression(&mut self, func: Expr, start: TextSize) -> ast::ExprCall {
636636
let arguments = self.parse_arguments();
637637

638638
ast::ExprCall {

crates/ruff_python_parser/src/parser/helpers.rs

+12
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,15 @@ pub(super) const fn token_kind_to_cmp_op(tokens: [TokenKind; 2]) -> Option<CmpOp
4343
_ => return None,
4444
})
4545
}
46+
47+
/// Helper for `parse_decorators` to determine if `expr` is a [`dotted_name`] from the decorator
48+
/// grammar before Python 3.9.
49+
///
50+
/// [`dotted_name`]: https://docs.python.org/3.8/reference/compound_stmts.html#grammar-token-dotted-name
51+
pub(super) fn is_name_or_attribute_expression(expr: &Expr) -> bool {
52+
match expr {
53+
Expr::Attribute(attr) => is_name_or_attribute_expression(&attr.value),
54+
Expr::Name(_) => true,
55+
_ => false,
56+
}
57+
}

crates/ruff_python_parser/src/parser/statement.rs

+52-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use rustc_hash::{FxBuildHasher, FxHashSet};
55

66
use ruff_python_ast::name::Name;
77
use ruff_python_ast::{
8-
self as ast, ExceptHandler, Expr, ExprContext, IpyEscapeKind, Operator, Stmt, WithItem,
8+
self as ast, ExceptHandler, Expr, ExprContext, IpyEscapeKind, Operator, PythonVersion, Stmt,
9+
WithItem,
910
};
1011
use ruff_text_size::{Ranged, TextRange, TextSize};
1112

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

2603+
let parsed_expr = self.parse_named_expression_or_higher(ExpressionContext::default());
2604+
2605+
if self.options.target_version < PythonVersion::PY39 {
2606+
// test_ok decorator_expression_dotted_ident_py38
2607+
// # parse_options: { "target-version": "3.8" }
2608+
// @buttons.clicked.connect
2609+
// def spam(): ...
2610+
2611+
// test_ok decorator_expression_identity_hack_py38
2612+
// # parse_options: { "target-version": "3.8" }
2613+
// def _(x): return x
2614+
// @_(buttons[0].clicked.connect)
2615+
// def spam(): ...
2616+
2617+
// test_ok decorator_expression_eval_hack_py38
2618+
// # parse_options: { "target-version": "3.8" }
2619+
// @eval("buttons[0].clicked.connect")
2620+
// def spam(): ...
2621+
2622+
// test_ok decorator_expression_py39
2623+
// # parse_options: { "target-version": "3.9" }
2624+
// @buttons[0].clicked.connect
2625+
// def spam(): ...
2626+
// @(x := lambda x: x)(foo)
2627+
// def bar(): ...
2628+
2629+
// test_err decorator_expression_py38
2630+
// # parse_options: { "target-version": "3.8" }
2631+
// @buttons[0].clicked.connect
2632+
// def spam(): ...
2633+
2634+
// test_err decorator_named_expression_py37
2635+
// # parse_options: { "target-version": "3.7" }
2636+
// @(x := lambda x: x)(foo)
2637+
// def bar(): ...
2638+
let allowed_decorator = match &parsed_expr.expr {
2639+
Expr::Call(expr_call) => {
2640+
helpers::is_name_or_attribute_expression(&expr_call.func)
2641+
}
2642+
expr => helpers::is_name_or_attribute_expression(expr),
2643+
};
2644+
2645+
if !allowed_decorator {
2646+
self.add_unsupported_syntax_error(
2647+
UnsupportedSyntaxErrorKind::RelaxedDecorator,
2648+
parsed_expr.range(),
2649+
);
2650+
}
2651+
}
2652+
26022653
// test_err decorator_invalid_expression
26032654
// @*x
26042655
// @(*x)
26052656
// @((*x))
26062657
// @yield x
26072658
// @yield from x
26082659
// def foo(): ...
2609-
let parsed_expr = self.parse_named_expression_or_higher(ExpressionContext::default());
26102660

26112661
decorators.push(ast::Decorator {
26122662
expression: parsed_expr.expr,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
source: crates/ruff_python_parser/tests/fixtures.rs
3+
input_file: crates/ruff_python_parser/resources/inline/err/decorator_expression_py38.py
4+
---
5+
## AST
6+
7+
```
8+
Module(
9+
ModModule {
10+
range: 0..89,
11+
body: [
12+
FunctionDef(
13+
StmtFunctionDef {
14+
range: 45..88,
15+
is_async: false,
16+
decorator_list: [
17+
Decorator {
18+
range: 45..72,
19+
expression: Attribute(
20+
ExprAttribute {
21+
range: 46..72,
22+
value: Attribute(
23+
ExprAttribute {
24+
range: 46..64,
25+
value: Subscript(
26+
ExprSubscript {
27+
range: 46..56,
28+
value: Name(
29+
ExprName {
30+
range: 46..53,
31+
id: Name("buttons"),
32+
ctx: Load,
33+
},
34+
),
35+
slice: NumberLiteral(
36+
ExprNumberLiteral {
37+
range: 54..55,
38+
value: Int(
39+
0,
40+
),
41+
},
42+
),
43+
ctx: Load,
44+
},
45+
),
46+
attr: Identifier {
47+
id: Name("clicked"),
48+
range: 57..64,
49+
},
50+
ctx: Load,
51+
},
52+
),
53+
attr: Identifier {
54+
id: Name("connect"),
55+
range: 65..72,
56+
},
57+
ctx: Load,
58+
},
59+
),
60+
},
61+
],
62+
name: Identifier {
63+
id: Name("spam"),
64+
range: 77..81,
65+
},
66+
type_params: None,
67+
parameters: Parameters {
68+
range: 81..83,
69+
posonlyargs: [],
70+
args: [],
71+
vararg: None,
72+
kwonlyargs: [],
73+
kwarg: None,
74+
},
75+
returns: None,
76+
body: [
77+
Expr(
78+
StmtExpr {
79+
range: 85..88,
80+
value: EllipsisLiteral(
81+
ExprEllipsisLiteral {
82+
range: 85..88,
83+
},
84+
),
85+
},
86+
),
87+
],
88+
},
89+
),
90+
],
91+
},
92+
)
93+
```
94+
## Unsupported Syntax Errors
95+
96+
|
97+
1 | # parse_options: { "target-version": "3.8" }
98+
2 | @buttons[0].clicked.connect
99+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Unsupported expression in decorators on Python 3.8 (syntax was added in Python 3.9)
100+
3 | def spam(): ...
101+
|

0 commit comments

Comments
 (0)