[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]: https://github.com/python/cpython/issues/78822
This commit is contained in:
Brent Westbrook 2025-03-06 12:18:13 -05:00 committed by GitHub
parent 6c14225c66
commit b3c884f4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 319 additions and 22 deletions

View file

@ -0,0 +1,4 @@
# parse_options: {"target-version": "3.8"}
f((a)=1)
f((a) = 1)
f( ( a ) = 1)

View file

@ -0,0 +1,2 @@
# parse_options: {"target-version": "3.7"}
f((a)=1)

View file

@ -436,11 +436,6 @@ pub struct UnsupportedSyntaxError {
pub kind: UnsupportedSyntaxErrorKind, pub kind: UnsupportedSyntaxErrorKind,
pub range: TextRange, pub range: TextRange,
/// The target [`PythonVersion`] for which this error was detected. /// The target [`PythonVersion`] for which this error was detected.
///
/// This is different from the version reported by the
/// [`minimum_version`](UnsupportedSyntaxErrorKind::minimum_version) method, which is the
/// earliest allowed version for this piece of syntax. The `target_version` is primarily used
/// for user-facing error messages.
pub target_version: PythonVersion, pub target_version: PythonVersion,
} }
@ -457,6 +452,26 @@ pub enum UnsupportedSyntaxErrorKind {
Walrus, Walrus,
ExceptStar, ExceptStar,
/// Represents the use of a parenthesized keyword argument name after Python 3.8.
///
/// ## Example
///
/// From [BPO 34641] it sounds like this was only accidentally supported and was removed when
/// noticed. Code like this used to be valid:
///
/// ```python
/// f((a)=1)
/// ```
///
/// After Python 3.8, you have to omit the parentheses around `a`:
///
/// ```python
/// f(a=1)
/// ```
///
/// [BPO 34641]: https://github.com/python/cpython/issues/78822
ParenthesizedKeywordArgumentName,
/// Represents the use of unparenthesized tuple unpacking in a `return` statement or `yield` /// Represents the use of unparenthesized tuple unpacking in a `return` statement or `yield`
/// expression before Python 3.8. /// expression before Python 3.8.
/// ///
@ -603,6 +618,9 @@ impl Display for UnsupportedSyntaxError {
UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement", UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement",
UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)", UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)",
UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`", UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`",
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => {
"Cannot use parenthesized keyword argument name"
}
UnsupportedSyntaxErrorKind::StarTuple(StarTupleKind::Return) => { UnsupportedSyntaxErrorKind::StarTuple(StarTupleKind::Return) => {
"Cannot use iterable unpacking in return statements" "Cannot use iterable unpacking in return statements"
} }
@ -619,32 +637,67 @@ impl Display for UnsupportedSyntaxError {
"Cannot set default type for a type parameter" "Cannot set default type for a type parameter"
} }
}; };
write!( write!(
f, f,
"{kind} on Python {} (syntax was added in Python {})", "{kind} on Python {} (syntax was {changed})",
self.target_version, self.target_version,
self.kind.minimum_version(), changed = self.kind.changed_version(),
) )
} }
} }
impl UnsupportedSyntaxErrorKind { /// Represents the kind of change in Python syntax between versions.
/// The earliest allowed version for the syntax associated with this error. enum Change {
pub const fn minimum_version(&self) -> PythonVersion { Added(PythonVersion),
Removed(PythonVersion),
}
impl Display for Change {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
UnsupportedSyntaxErrorKind::Match => PythonVersion::PY310, Change::Added(version) => write!(f, "added in Python {version}"),
UnsupportedSyntaxErrorKind::Walrus => PythonVersion::PY38, Change::Removed(version) => write!(f, "removed in Python {version}"),
UnsupportedSyntaxErrorKind::ExceptStar => PythonVersion::PY311,
UnsupportedSyntaxErrorKind::StarTuple(_) => PythonVersion::PY38,
UnsupportedSyntaxErrorKind::RelaxedDecorator => PythonVersion::PY39,
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => PythonVersion::PY38,
UnsupportedSyntaxErrorKind::TypeParameterList => PythonVersion::PY312,
UnsupportedSyntaxErrorKind::TypeAliasStatement => PythonVersion::PY312,
UnsupportedSyntaxErrorKind::TypeParamDefault => PythonVersion::PY313,
} }
} }
} }
impl UnsupportedSyntaxErrorKind {
/// Returns the Python version when the syntax associated with this error was changed, and the
/// type of [`Change`] (added or removed).
const fn changed_version(self) -> Change {
match self {
UnsupportedSyntaxErrorKind::Match => Change::Added(PythonVersion::PY310),
UnsupportedSyntaxErrorKind::Walrus => Change::Added(PythonVersion::PY38),
UnsupportedSyntaxErrorKind::ExceptStar => Change::Added(PythonVersion::PY311),
UnsupportedSyntaxErrorKind::StarTuple(_) => Change::Added(PythonVersion::PY38),
UnsupportedSyntaxErrorKind::RelaxedDecorator => Change::Added(PythonVersion::PY39),
UnsupportedSyntaxErrorKind::PositionalOnlyParameter => {
Change::Added(PythonVersion::PY38)
}
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => {
Change::Removed(PythonVersion::PY38)
}
UnsupportedSyntaxErrorKind::TypeParameterList => Change::Added(PythonVersion::PY312),
UnsupportedSyntaxErrorKind::TypeAliasStatement => Change::Added(PythonVersion::PY312),
UnsupportedSyntaxErrorKind::TypeParamDefault => Change::Added(PythonVersion::PY313),
}
}
/// Returns whether or not this kind of syntax is unsupported on `target_version`.
pub(crate) fn is_unsupported(self, target_version: PythonVersion) -> bool {
match self.changed_version() {
Change::Added(version) => target_version < version,
Change::Removed(version) => target_version >= version,
}
}
/// Returns `true` if this kind of syntax is supported on `target_version`.
pub(crate) fn is_supported(self, target_version: PythonVersion) -> bool {
!self.is_unsupported(target_version)
}
}
#[cfg(target_pointer_width = "64")] #[cfg(target_pointer_width = "64")]
mod sizes { mod sizes {
use crate::error::{LexicalError, LexicalErrorType}; use crate::error::{LexicalError, LexicalErrorType};

View file

@ -702,9 +702,31 @@ impl<'src> Parser<'src> {
} }
} }
let arg_range = parser.node_range(start);
if parser.eat(TokenKind::Equal) { if parser.eat(TokenKind::Equal) {
seen_keyword_argument = true; seen_keyword_argument = true;
let arg = if let Expr::Name(ident_expr) = parsed_expr.expr { let arg = if let ParsedExpr {
expr: Expr::Name(ident_expr),
is_parenthesized,
} = parsed_expr
{
// test_ok parenthesized_kwarg_py37
// # parse_options: {"target-version": "3.7"}
// f((a)=1)
// test_err parenthesized_kwarg_py38
// # parse_options: {"target-version": "3.8"}
// f((a)=1)
// f((a) = 1)
// f( ( a ) = 1)
if is_parenthesized {
parser.add_unsupported_syntax_error(
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName,
arg_range,
);
}
ast::Identifier { ast::Identifier {
id: ident_expr.id, id: ident_expr.id,
range: ident_expr.range, range: ident_expr.range,

View file

@ -441,7 +441,7 @@ impl<'src> Parser<'src> {
/// Add an [`UnsupportedSyntaxError`] with the given [`UnsupportedSyntaxErrorKind`] and /// Add an [`UnsupportedSyntaxError`] with the given [`UnsupportedSyntaxErrorKind`] and
/// [`TextRange`] if its minimum version is less than [`Parser::target_version`]. /// [`TextRange`] if its minimum version is less than [`Parser::target_version`].
fn add_unsupported_syntax_error(&mut self, kind: UnsupportedSyntaxErrorKind, range: TextRange) { fn add_unsupported_syntax_error(&mut self, kind: UnsupportedSyntaxErrorKind, range: TextRange) {
if self.options.target_version < kind.minimum_version() { if kind.is_unsupported(self.options.target_version) {
self.unsupported_syntax_errors.push(UnsupportedSyntaxError { self.unsupported_syntax_errors.push(UnsupportedSyntaxError {
kind, kind,
range, range,

View file

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

View file

@ -0,0 +1,161 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py
---
## AST
```
Module(
ModModule {
range: 0..77,
body: [
Expr(
StmtExpr {
range: 43..51,
value: Call(
ExprCall {
range: 43..51,
func: Name(
ExprName {
range: 43..44,
id: Name("f"),
ctx: Load,
},
),
arguments: Arguments {
range: 44..51,
args: [],
keywords: [
Keyword {
range: 45..50,
arg: Some(
Identifier {
id: Name("a"),
range: 46..47,
},
),
value: NumberLiteral(
ExprNumberLiteral {
range: 49..50,
value: Int(
1,
),
},
),
},
],
},
},
),
},
),
Expr(
StmtExpr {
range: 52..62,
value: Call(
ExprCall {
range: 52..62,
func: Name(
ExprName {
range: 52..53,
id: Name("f"),
ctx: Load,
},
),
arguments: Arguments {
range: 53..62,
args: [],
keywords: [
Keyword {
range: 54..61,
arg: Some(
Identifier {
id: Name("a"),
range: 55..56,
},
),
value: NumberLiteral(
ExprNumberLiteral {
range: 60..61,
value: Int(
1,
),
},
),
},
],
},
},
),
},
),
Expr(
StmtExpr {
range: 63..76,
value: Call(
ExprCall {
range: 63..76,
func: Name(
ExprName {
range: 63..64,
id: Name("f"),
ctx: Load,
},
),
arguments: Arguments {
range: 64..76,
args: [],
keywords: [
Keyword {
range: 66..75,
arg: Some(
Identifier {
id: Name("a"),
range: 68..69,
},
),
value: NumberLiteral(
ExprNumberLiteral {
range: 74..75,
value: Int(
1,
),
},
),
},
],
},
},
),
},
),
],
},
)
```
## Unsupported Syntax Errors
|
1 | # parse_options: {"target-version": "3.8"}
2 | f((a)=1)
| ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
3 | f((a) = 1)
4 | f( ( a ) = 1)
|
|
1 | # parse_options: {"target-version": "3.8"}
2 | f((a)=1)
3 | f((a) = 1)
| ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
4 | f( ( a ) = 1)
|
|
2 | f((a)=1)
3 | f((a) = 1)
4 | f( ( a ) = 1)
| ^^^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8)
|

View file

@ -0,0 +1,55 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/ok/parenthesized_kwarg_py37.py
---
## AST
```
Module(
ModModule {
range: 0..52,
body: [
Expr(
StmtExpr {
range: 43..51,
value: Call(
ExprCall {
range: 43..51,
func: Name(
ExprName {
range: 43..44,
id: Name("f"),
ctx: Load,
},
),
arguments: Arguments {
range: 44..51,
args: [],
keywords: [
Keyword {
range: 45..50,
arg: Some(
Identifier {
id: Name("a"),
range: 46..47,
},
),
value: NumberLiteral(
ExprNumberLiteral {
range: 49..50,
value: Int(
1,
),
},
),
},
],
},
},
),
},
),
],
},
)
```