Skip to content

Commit b3c884f

Browse files
authored
[syntax-errors] Parenthesized keyword argument names after Python 3.8 (#16482)
Summary -- Unlike the other syntax errors detected so far, parenthesized keyword arguments are only allowed *before* 3.8. It sounds like they were only accidentally allowed before that [^1]. As an aside, you get a pretty confusing error from Python for this, so it's nice that we can catch it: ```pycon >>> def f(**kwargs): ... ... f((a)=1) ... File "<python-input-0>", line 2 f((a)=1) ^^^ SyntaxError: expression cannot contain assignment, perhaps you meant "=="? >>> ``` Test Plan -- Inline tests. [^1]: python/cpython#78822
1 parent 6c14225 commit b3c884f

File tree

8 files changed

+318
-21
lines changed

8 files changed

+318
-21
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# parse_options: {"target-version": "3.8"}
2+
f((a)=1)
3+
f((a) = 1)
4+
f( ( a ) = 1)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# parse_options: {"target-version": "3.7"}
2+
f((a)=1)

crates/ruff_python_parser/src/error.rs

+71-18
Original file line numberDiff line numberDiff line change
@@ -436,11 +436,6 @@ pub struct UnsupportedSyntaxError {
436436
pub kind: UnsupportedSyntaxErrorKind,
437437
pub range: TextRange,
438438
/// The target [`PythonVersion`] for which this error was detected.
439-
///
440-
/// This is different from the version reported by the
441-
/// [`minimum_version`](UnsupportedSyntaxErrorKind::minimum_version) method, which is the
442-
/// earliest allowed version for this piece of syntax. The `target_version` is primarily used
443-
/// for user-facing error messages.
444439
pub target_version: PythonVersion,
445440
}
446441

@@ -457,6 +452,26 @@ pub enum UnsupportedSyntaxErrorKind {
457452
Walrus,
458453
ExceptStar,
459454

455+
/// Represents the use of a parenthesized keyword argument name after Python 3.8.
456+
///
457+
/// ## Example
458+
///
459+
/// From [BPO 34641] it sounds like this was only accidentally supported and was removed when
460+
/// noticed. Code like this used to be valid:
461+
///
462+
/// ```python
463+
/// f((a)=1)
464+
/// ```
465+
///
466+
/// After Python 3.8, you have to omit the parentheses around `a`:
467+
///
468+
/// ```python
469+
/// f(a=1)
470+
/// ```
471+
///
472+
/// [BPO 34641]: https://github.com/python/cpython/issues/78822
473+
ParenthesizedKeywordArgumentName,
474+
460475
/// Represents the use of unparenthesized tuple unpacking in a `return` statement or `yield`
461476
/// expression before Python 3.8.
462477
///
@@ -603,6 +618,9 @@ impl Display for UnsupportedSyntaxError {
603618
UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement",
604619
UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)",
605620
UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`",
621+
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => {
622+
"Cannot use parenthesized keyword argument name"
623+
}
606624
UnsupportedSyntaxErrorKind::StarTuple(StarTupleKind::Return) => {
607625
"Cannot use iterable unpacking in return statements"
608626
}
@@ -619,30 +637,65 @@ impl Display for UnsupportedSyntaxError {
619637
"Cannot set default type for a type parameter"
620638
}
621639
};
640+
622641
write!(
623642
f,
624-
"{kind} on Python {} (syntax was added in Python {})",
643+
"{kind} on Python {} (syntax was {changed})",
625644
self.target_version,
626-
self.kind.minimum_version(),
645+
changed = self.kind.changed_version(),
627646
)
628647
}
629648
}
630649

650+
/// Represents the kind of change in Python syntax between versions.
651+
enum Change {
652+
Added(PythonVersion),
653+
Removed(PythonVersion),
654+
}
655+
656+
impl Display for Change {
657+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
658+
match self {
659+
Change::Added(version) => write!(f, "added in Python {version}"),
660+
Change::Removed(version) => write!(f, "removed in Python {version}"),
661+
}
662+
}
663+
}
664+
631665
impl UnsupportedSyntaxErrorKind {
632-
/// The earliest allowed version for the syntax associated with this error.
633-
pub const fn minimum_version(&self) -> PythonVersion {
666+
/// Returns the Python version when the syntax associated with this error was changed, and the
667+
/// type of [`Change`] (added or removed).
668+
const fn changed_version(self) -> Change {
634669
match self {
635-
UnsupportedSyntaxErrorKind::Match => PythonVersion::PY310,
636-
UnsupportedSyntaxErrorKind::Walrus => PythonVersion::PY38,
637-
UnsupportedSyntaxErrorKind::ExceptStar => PythonVersion::PY311,
638-
UnsupportedSyntaxErrorKind::StarTuple(_) => PythonVersion::PY38,
639-
UnsupportedSyntaxErrorKind::RelaxedDecorator => PythonVersion::PY39,
640-
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => PythonVersion::PY38,
641-
UnsupportedSyntaxErrorKind::TypeParameterList => PythonVersion::PY312,
642-
UnsupportedSyntaxErrorKind::TypeAliasStatement => PythonVersion::PY312,
643-
UnsupportedSyntaxErrorKind::TypeParamDefault => PythonVersion::PY313,
670+
UnsupportedSyntaxErrorKind::Match => Change::Added(PythonVersion::PY310),
671+
UnsupportedSyntaxErrorKind::Walrus => Change::Added(PythonVersion::PY38),
672+
UnsupportedSyntaxErrorKind::ExceptStar => Change::Added(PythonVersion::PY311),
673+
UnsupportedSyntaxErrorKind::StarTuple(_) => Change::Added(PythonVersion::PY38),
674+
UnsupportedSyntaxErrorKind::RelaxedDecorator => Change::Added(PythonVersion::PY39),
675+
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
676+
Change::Added(PythonVersion::PY38)
677+
}
678+
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => {
679+
Change::Removed(PythonVersion::PY38)
680+
}
681+
UnsupportedSyntaxErrorKind::TypeParameterList => Change::Added(PythonVersion::PY312),
682+
UnsupportedSyntaxErrorKind::TypeAliasStatement => Change::Added(PythonVersion::PY312),
683+
UnsupportedSyntaxErrorKind::TypeParamDefault => Change::Added(PythonVersion::PY313),
644684
}
645685
}
686+
687+
/// Returns whether or not this kind of syntax is unsupported on `target_version`.
688+
pub(crate) fn is_unsupported(self, target_version: PythonVersion) -> bool {
689+
match self.changed_version() {
690+
Change::Added(version) => target_version < version,
691+
Change::Removed(version) => target_version >= version,
692+
}
693+
}
694+
695+
/// Returns `true` if this kind of syntax is supported on `target_version`.
696+
pub(crate) fn is_supported(self, target_version: PythonVersion) -> bool {
697+
!self.is_unsupported(target_version)
698+
}
646699
}
647700

648701
#[cfg(target_pointer_width = "64")]

crates/ruff_python_parser/src/parser/expression.rs

+23-1
Original file line numberDiff line numberDiff line change
@@ -702,9 +702,31 @@ impl<'src> Parser<'src> {
702702
}
703703
}
704704

705+
let arg_range = parser.node_range(start);
705706
if parser.eat(TokenKind::Equal) {
706707
seen_keyword_argument = true;
707-
let arg = if let Expr::Name(ident_expr) = parsed_expr.expr {
708+
let arg = if let ParsedExpr {
709+
expr: Expr::Name(ident_expr),
710+
is_parenthesized,
711+
} = parsed_expr
712+
{
713+
// test_ok parenthesized_kwarg_py37
714+
// # parse_options: {"target-version": "3.7"}
715+
// f((a)=1)
716+
717+
// test_err parenthesized_kwarg_py38
718+
// # parse_options: {"target-version": "3.8"}
719+
// f((a)=1)
720+
// f((a) = 1)
721+
// f( ( a ) = 1)
722+
723+
if is_parenthesized {
724+
parser.add_unsupported_syntax_error(
725+
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName,
726+
arg_range,
727+
);
728+
}
729+
708730
ast::Identifier {
709731
id: ident_expr.id,
710732
range: ident_expr.range,

crates/ruff_python_parser/src/parser/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ impl<'src> Parser<'src> {
441441
/// Add an [`UnsupportedSyntaxError`] with the given [`UnsupportedSyntaxErrorKind`] and
442442
/// [`TextRange`] if its minimum version is less than [`Parser::target_version`].
443443
fn add_unsupported_syntax_error(&mut self, kind: UnsupportedSyntaxErrorKind, range: TextRange) {
444-
if self.options.target_version < kind.minimum_version() {
444+
if kind.is_unsupported(self.options.target_version) {
445445
self.unsupported_syntax_errors.push(UnsupportedSyntaxError {
446446
kind,
447447
range,

crates/ruff_python_parser/src/parser/statement.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ impl<'src> Parser<'src> {
424424
/// are only allowed in Python 3.8 and later: <https://github.com/python/cpython/issues/76298>.
425425
pub(crate) fn check_tuple_unpacking(&mut self, expr: &Expr, kind: StarTupleKind) {
426426
let kind = UnsupportedSyntaxErrorKind::StarTuple(kind);
427-
if self.options.target_version >= kind.minimum_version() {
427+
if kind.is_supported(self.options.target_version) {
428428
return;
429429
}
430430

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
source: crates/ruff_python_parser/tests/fixtures.rs
3+
input_file: crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py
4+
---
5+
## AST
6+
7+
```
8+
Module(
9+
ModModule {
10+
range: 0..77,
11+
body: [
12+
Expr(
13+
StmtExpr {
14+
range: 43..51,
15+
value: Call(
16+
ExprCall {
17+
range: 43..51,
18+
func: Name(
19+
ExprName {
20+
range: 43..44,
21+
id: Name("f"),
22+
ctx: Load,
23+
},
24+
),
25+
arguments: Arguments {
26+
range: 44..51,
27+
args: [],
28+
keywords: [
29+
Keyword {
30+
range: 45..50,
31+
arg: Some(
32+
Identifier {
33+
id: Name("a"),
34+
range: 46..47,
35+
},
36+
),
37+
value: NumberLiteral(
38+
ExprNumberLiteral {
39+
range: 49..50,
40+
value: Int(
41+
1,
42+
),
43+
},
44+
),
45+
},
46+
],
47+
},
48+
},
49+
),
50+
},
51+
),
52+
Expr(
53+
StmtExpr {
54+
range: 52..62,
55+
value: Call(
56+
ExprCall {
57+
range: 52..62,
58+
func: Name(
59+
ExprName {
60+
range: 52..53,
61+
id: Name("f"),
62+
ctx: Load,
63+
},
64+
),
65+
arguments: Arguments {
66+
range: 53..62,
67+
args: [],
68+
keywords: [
69+
Keyword {
70+
range: 54..61,
71+
arg: Some(
72+
Identifier {
73+
id: Name("a"),
74+
range: 55..56,
75+
},
76+
),
77+
value: NumberLiteral(
78+
ExprNumberLiteral {
79+
range: 60..61,
80+
value: Int(
81+
1,
82+
),
83+
},
84+
),
85+
},
86+
],
87+
},
88+
},
89+
),
90+
},
91+
),
92+
Expr(
93+
StmtExpr {
94+
range: 63..76,
95+
value: Call(
96+
ExprCall {
97+
range: 63..76,
98+
func: Name(
99+
ExprName {
100+
range: 63..64,
101+
id: Name("f"),
102+
ctx: Load,
103+
},
104+
),
105+
arguments: Arguments {
106+
range: 64..76,
107+
args: [],
108+
keywords: [
109+
Keyword {
110+
range: 66..75,
111+
arg: Some(
112+
Identifier {
113+
id: Name("a"),
114+
range: 68..69,
115+
},
116+
),
117+
value: NumberLiteral(
118+
ExprNumberLiteral {
119+
range: 74..75,
120+
value: Int(
121+
1,
122+
),
123+
},
124+
),
125+
},
126+
],
127+
},
128+
},
129+
),
130+
},
131+
),
132+
],
133+
},
134+
)
135+
```
136+
## Unsupported Syntax Errors
137+
138+
|
139+
1 | # parse_options: {"target-version": "3.8"}
140+
2 | f((a)=1)
141+
| ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
142+
3 | f((a) = 1)
143+
4 | f( ( a ) = 1)
144+
|
145+
146+
147+
|
148+
1 | # parse_options: {"target-version": "3.8"}
149+
2 | f((a)=1)
150+
3 | f((a) = 1)
151+
| ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
152+
4 | f( ( a ) = 1)
153+
|
154+
155+
156+
|
157+
2 | f((a)=1)
158+
3 | f((a) = 1)
159+
4 | f( ( a ) = 1)
160+
| ^^^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
161+
|

0 commit comments

Comments
 (0)